diff --git a/.gitignore b/.gitignore index 533ecf24c..dc4b3792c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target/ +node_modules/ .docker-context/ *.log .idea/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4dd6604f7..3fd5c31fe 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,6 +13,55 @@ cache: paths: - .m2/repository/ +Java拓扑合同: + stage: verify + image: node:22-alpine + cache: [] + script: + - node --test scripts/java-topology-contract.test.mjs + +OpenAPI有效性: + stage: verify + image: node:22-alpine + cache: [] + script: + - npm ci --ignore-scripts --prefix scripts/openapi-validation + - npm test --prefix scripts/openapi-validation + - npm run validate --prefix scripts/openapi-validation + +静态分析报告: + stage: verify + allow_failure: true + script: + - mvn $MAVEN_CLI_OPTS -Pstatic-analysis-report -DskipTests verify + artifacts: + when: always + expire_in: 7 days + paths: + - "**/target/spotbugs*.xml" + - "**/target/site/spotbugs.html" + +质量摘要: + stage: verify + image: node:22-alpine + cache: [] + allow_failure: true + needs: + - job: OpenAPI有效性 + artifacts: false + - job: 校验与测试 + artifacts: true + - job: 静态分析报告 + artifacts: true + script: + - node --test scripts/java-quality-summary.test.mjs + - node scripts/java-quality-summary.mjs --output target/java-quality-summary.json --require-jacoco --require-spotbugs + artifacts: + when: always + expire_in: 7 days + paths: + - target/java-quality-summary.json + 校验与测试: stage: verify script: @@ -25,10 +74,14 @@ cache: - "**/target/surefire-reports/TEST-*.xml" paths: - "**/target/surefire-reports/" + - "**/target/site/jacoco/" + - "**/target/jacoco.exec" 打包服务: stage: package needs: + - Java拓扑合同 + - OpenAPI有效性 - 校验与测试 script: - mvn $MAVEN_CLI_OPTS -DskipTests package @@ -36,14 +89,17 @@ cache: expire_in: 7 days paths: - gateway/target/*.jar + - gateway-overseas/target/*.jar - auth/target/*.jar - iam/target/*.jar - order/target/*.jar - firstleg/target/*.jar + - finance/target/*.jar - warehouse/target/*.jar + - warehouse-overseas-service/target/*.jar + - lastleg-service/target/*.jar - file/target/*.jar - print/target/*.jar - audit-service/target/*.jar - integration-service/target/*.jar - event-bridge-service/target/*.jar - diff --git a/.runtime-tailleg/DbQuery.class b/.runtime-tailleg/DbQuery.class new file mode 100644 index 000000000..1d188b78e Binary files /dev/null and b/.runtime-tailleg/DbQuery.class differ diff --git a/.runtime-tailleg/DbQuery.java b/.runtime-tailleg/DbQuery.java new file mode 100644 index 000000000..42d412c31 --- /dev/null +++ b/.runtime-tailleg/DbQuery.java @@ -0,0 +1,39 @@ +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; + +public final class DbQuery { + public static void main(String[] args) throws Exception { + if (args.length < 2) { + throw new IllegalArgumentException("usage: DbQuery "); + } + String url = "jdbc:mysql://127.0.0.1:3306/" + args[0] + + "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai" + + "&useSSL=false&allowPublicKeyRetrieval=true"; + try (Connection connection = DriverManager.getConnection(url, "root", "123456"); + Statement statement = connection.createStatement()) { + boolean hasResult = statement.execute(args[1]); + if (!hasResult) { + System.out.println("updateCount=" + statement.getUpdateCount()); + return; + } + try (ResultSet result = statement.getResultSet()) { + ResultSetMetaData meta = result.getMetaData(); + for (int column = 1; column <= meta.getColumnCount(); column++) { + if (column > 1) System.out.print("\t"); + System.out.print(meta.getColumnLabel(column)); + } + System.out.println(); + while (result.next()) { + for (int column = 1; column <= meta.getColumnCount(); column++) { + if (column > 1) System.out.print("\t"); + System.out.print(String.valueOf(result.getObject(column))); + } + System.out.println(); + } + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md index cbee3f977..cba94bc2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,16 +1,17 @@ -# 国内头程后端项目指引 +# 头程与海外仓后端项目指引 ## 适用范围与定位 -- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库;进入具体服务后继续读取该服务的 `AGENTS.md`。 +- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。 - 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。 - 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`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`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。 +- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。 - 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。 ## 服务路由(按关键词定位) @@ -19,12 +20,16 @@ |---|---|---| | 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | +| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` | | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | +| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 | +| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | | 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` | | 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | +| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` | | 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` | | 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` | @@ -32,7 +37,29 @@ | 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` | | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | -| PDF 标签、审计、事件 | `print/`、`audit-service/`、`event-bridge-service/` | 各服务指引 | +| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | +| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | +| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | +| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | +| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | +| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | +| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | +| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | +| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | +| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | + +## 公共模块快速索引 + +| 关键词 | 模块与入口 | +|---|---| +| 公共依赖版本 | `platform-bom/pom.xml`;统一管理公共组件版本,不放业务实现 | +| 通用响应、分页、异常、基础实体与导出契约 | `common-core/`;从 `ApiResponse.java`、`PageResult.java`、`BizException.java`、`ExportContracts.java` 定位 | +| Web 异常、请求关联/Trace、同步出站传播、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`TraceParent.java`、`FeignTraceRequestInterceptor.java`、`RestClientTraceInterceptor.java`、`LongIdJacksonConfiguration.java` 定位 | +| JWT、JWKS、会话校验与认证用户 | `common-security/`;从 `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java`、`AccessTokenSessionValidator.java` 定位 | +| Springdoc/OpenAPI 自动配置 | `common-openapi/`;从 `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` 定位 | +| 业务审计注解、脱敏、Outbox 与投递 | `common-audit/`;从 `BusinessAudit.java`、`BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`AuditOutboxPublisher.java` 定位 | +| 跨区域事件信封、Payload 与哈希 | `common-event-contract/`;继续读该目录 `AGENTS.md` | ## 全量验收路由 @@ -43,7 +70,8 @@ - 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 - 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。 -- 头程与海外仓的入库单、装柜结果、费用来源和财务回传走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 +- 头程装柜完成向海外建尾程单、海外 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` 不读取尾程订单库。 ## 契约、数据库与验证入口 @@ -53,8 +81,11 @@ - 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。 - 权限语义变更必须同步 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/` 不属于源码。 - 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`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 @@ -62,6 +93,7 @@ - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 +- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 - HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。 ## 阶段文档入口 @@ -69,6 +101,7 @@ - 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md` - 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告 - 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单 +- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。 diff --git a/Jenkinsfile b/Jenkinsfile index fc17895a0..1c944c197 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,10 +1,11 @@ def serviceModules = [ - 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'event-bridge-service', 'audit-service' + 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceCheckboxes = [ 'gateway': 'SERVICE_GATEWAY', + 'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS', 'auth': 'SERVICE_AUTH', 'iam': 'SERVICE_IAM', 'firstleg': 'SERVICE_FIRSTLEG', @@ -14,26 +15,95 @@ def serviceCheckboxes = [ 'file': 'SERVICE_FILE', 'print': 'SERVICE_PRINT', '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 commonPaths = [ - 'pom.xml', 'platform-bom/', 'common-core/', 'common-web/', 'common-security/', - 'common-openapi/', 'common-audit/', 'common-event-contract/', 'Jenkinsfile', - 'Jenkinsfile.service', 'deploy/' +def fullRebuildPaths = [ + 'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/', + 'deploy/docker-compose.yml', 'deploy/service-map.yml' +] + +def commonPathServices = [ + 'common-web/': [ + 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', + 'event-bridge-service', 'audit-service' + ], + 'common-security/': [ + 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', + 'lastleg-service', 'warehouse-overseas-service', 'audit-service' + ], + 'common-openapi/': [ + 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', + 'event-bridge-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' + ], + 'deploy/docker-compose.audit.yml': ['audit-service'], + 'deploy/docker-compose.rocketmq.yml': ['event-bridge-service'] ] def databaseMigrationPaths = ['deploy/sql/'] def downstreamResults = [:] +def parseAutoBuildState(String description, List allowedServices) { + String marker = ' | RESULTS' + if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null + int markerIndex = description.indexOf(marker) + String commit = description.substring('AUTO '.length(), markerIndex).trim() + if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null + String summary = description.substring(markerIndex + marker.length()).trim() + List failedServices = [] + if (summary) { + summary.split(',').collect { it.trim() }.findAll { it }.each { item -> + int separator = item.indexOf('=') + if (separator > 0) { + String service = item.substring(0, separator).trim() + String result = item.substring(separator + 1).trim() + if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service + } + } + } + return [commit: commit, failedServices: failedServices.unique()] +} + +def previousAutoBuildState(def previousBuild, List allowedServices) { + def candidate = previousBuild + int inspected = 0 + while (candidate != null && inspected < 30) { + def state = parseAutoBuildState(candidate.description, allowedServices) + if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state + candidate = candidate.previousBuild + inspected++ + } + return null +} + def environmentChoicesForJob(String jobBaseName) { return jobBaseName == 'backend-java-dev' ? ['development', 'test', 'staging', 'production'] : ['test', 'development', 'staging', 'production'] } +def downstreamJobPrefixForJob(String jobBaseName) { + return jobBaseName == 'backend-java-dev' + ? 'baiyue-firstleg-dev-' + : 'baiyue-firstleg-' +} + pipeline { // 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。 agent none @@ -47,6 +117,7 @@ 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_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)') @@ -56,14 +127,17 @@ pipeline { booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)') booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)') booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') - booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-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: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') - string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: 'baiyue-firstleg-', description: '单服务 Jenkins Job 名称前缀,例如 baiyue-firstleg-auth') + string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: downstreamJobPrefixForJob(env.JOB_BASE_NAME), description: '单服务 Jenkins Job 名称前缀;开发总控自动使用 dev 专属 Job') } stages { @@ -95,7 +169,12 @@ pipeline { } env.RESOLVED_JOB_PREFIX = jobPrefix env.DATABASE_CHANGE_DETECTED = 'false' + if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) { + error('生产环境禁止跳过测试') + } + 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() List selected = [] if (params.BUILD_MODE == 'ALL') { selected = serviceModules @@ -107,9 +186,17 @@ pipeline { error('SELECT 模式至少需要勾选一个服务') } } else { - String base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim() + def autoState = previousAutoBuildState(currentBuild.previousBuild, serviceModules) + String base = autoState?.commit?.trim() + List retryServices = autoState?.failedServices ?: [] + if (!base) { + // 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录, + // SELECT/ALL 不会推进 AUTO 基线。 + base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim() + } if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) { selected = serviceModules + echo 'AUTO 未找到可用基线,安全回退为全量构建' } else { List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true) .trim().split('\\n').collect { it.trim() }.findAll { it } @@ -117,13 +204,27 @@ pipeline { databaseMigrationPaths.any { prefix -> path.startsWith(prefix) } } env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false' - boolean commonChanged = changed.any { path -> - boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) } - !databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) } + List serviceRelevantChanges = changed.findAll { path -> + !databaseMigrationPaths.any { prefix -> path.startsWith(prefix) } } - selected = commonChanged ? serviceModules : serviceModules.findAll { module -> - changed.any { it == module || it.startsWith("${module}/") } + boolean fullRebuild = serviceRelevantChanges.any { path -> + fullRebuildPaths.any { prefix -> path == prefix || path.startsWith(prefix) } } + if (fullRebuild) { + selected = serviceModules + } else { + selected = serviceModules.findAll { module -> + serviceRelevantChanges.any { it == module || it.startsWith("${module}/") } + } + commonPathServices.each { prefix, impactedServices -> + if (serviceRelevantChanges.any { path -> path == prefix || path.startsWith(prefix) }) { + selected.addAll(impactedServices) + } + } + } + selected.addAll(retryServices) + echo "AUTO 基线: ${base};变更选中: ${selected.unique().join(',') ?: '无'};" + + "补跑失败服务: ${retryServices.join(',') ?: '无'}" } } @@ -133,17 +234,32 @@ 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 (['BOTH', 'SELECT'].contains(bridgeRegion) && + !['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) { + error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') + } + if (bridgeRegion == 'SELECT') { + echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥' + } + } env.SELECTED_SERVICES = selected.join(',') env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' if (!selected) { - currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true' - ? '仅数据库迁移变更 | 需单独审核执行' - : '没有需要构建的业务服务' + currentBuild.description = params.BUILD_MODE == 'AUTO' + ? "AUTO ${env.IMAGE_TAG} | RESULTS" + : (env.DATABASE_CHANGE_DETECTED == 'true' + ? '仅数据库迁移变更 | 需单独审核执行' + : '没有需要构建的业务服务') } else { - 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() String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : '' - currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}" + currentBuild.description = params.BUILD_MODE == 'AUTO' + ? "AUTO ${env.IMAGE_TAG} | PENDING ${env.SELECTED_SERVICES}${databaseSuffix}" + : "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}" echo "本次独立调度服务: ${env.SELECTED_SERVICES}" } if (env.DATABASE_CHANGE_DETECTED == 'true') { @@ -164,6 +280,33 @@ pipeline { } } + stage('统一 Maven 测试门禁') { + when { + expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS } + } + agent { label 'java-builder' } + tools { maven 'maven-3.8.8' } + steps { + script { + String selectedModules = env.SELECTED_SERVICES.split(',') + .collect { it.trim() } + .findAll { it } + .join(',') + echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" + sh """ + test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" + mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test + """ + echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试" + } + } + post { + always { + junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml' + } + } + } + stage('并行调度独立服务 Job') { when { expression { env.NO_SERVICE_CHANGE != 'true' } } steps { @@ -173,6 +316,14 @@ 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, @@ -180,12 +331,16 @@ pipeline { propagate: false, parameters: [ string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), - string(name: 'IMAGE_TAG', value: env.IMAGE_TAG), + string(name: 'IMAGE_TAG', value: downstreamImageTag), string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT), booleanParam(name: 'DEPLOY', value: params.DEPLOY), - booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS), + // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。 + 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) + string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE), + // Choice 参数始终传递所有新旧 Job 都接受的单区值; + // 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。 + string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion) ] ) downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN' @@ -205,7 +360,9 @@ pipeline { .collect { service, result -> "${service}=${result}" } String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ') echo "单服务执行汇总: ${summary}" - currentBuild.description = "${env.IMAGE_TAG} | ${summary}" + currentBuild.description = params.BUILD_MODE == 'AUTO' + ? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}" + : "${env.IMAGE_TAG} | ${summary}" if (failed) { unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}") } diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 92815a1c9..bd81515d2 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,10 +1,11 @@ def serviceModules = [ - 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'event-bridge-service', 'audit-service' + 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-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', @@ -14,6 +15,8 @@ def serviceJars = [ 'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar', 'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar', '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' ] @@ -35,6 +38,7 @@ pipeline { choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') 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: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') } @@ -72,6 +76,11 @@ pipeline { if (!serviceModules.contains(service)) { error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") } + boolean deployBothBridges = service == 'event-bridge-service' && + imageTag?.endsWith('-both') + if (deployBothBridges) { + imageTag = imageTag.substring(0, imageTag.length() - '-both'.length()) + } if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') } @@ -89,6 +98,18 @@ 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') + } + if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) && + !['development', 'test'].contains(params.ENVIRONMENT)) { + error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') + } + } } sh ''' @@ -110,12 +131,15 @@ pipeline { stage('Maven 测试与打包') { steps { script { - if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) { - error('生产环境禁止跳过测试') + boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean + if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) { + error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包') } boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS String testOption = skipTestExecution ? '-DskipTests' : '' - if (params.ENVIRONMENT == 'test') { + if (params.SKIP_TESTS && coordinatorPinnedCommit) { + echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" + } else if (params.ENVIRONMENT == 'test') { echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行' } sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" @@ -135,16 +159,21 @@ pipeline { String contextDir = ".docker-context/${env.RESOLVED_SERVICE}" String jar = serviceJars[env.RESOLVED_SERVICE] String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0] + String dockerConfigDir = "${pwd(tmp: true)}/docker-auth" sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'" - sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'" echo "推送共享镜像: ${image}" - withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { - try { - sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"' - sh "docker push ${image}" - } finally { - sh "docker logout ${registryHost} || true" + withEnv(["DOCKER_CONFIG=${dockerConfigDir}"]) { + sh 'mkdir -p "$DOCKER_CONFIG"' + withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { + try { + sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"' + sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'" + sh "docker push ${image}" + } finally { + sh "docker logout ${registryHost} || true" + sh 'rm -rf "$DOCKER_CONFIG"' + } } } if (params.DEPLOY) { @@ -161,36 +190,52 @@ pipeline { beforeAgent true expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) } } - agent { - label "${params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'}" - } + agent none steps { - deleteDir() - unstash 'deployment-files' script { - String deployLock = "firstleg-${params.ENVIRONMENT}-deploy" + String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot" + String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" String credentialId = "firstleg-${params.ENVIRONMENT}-env" - lock(resource: deployLock, inversePrecedence: true) { - echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" - withEnv([ - 'TARGET_IS_LOCAL=true' - ]) { - withCredentials([ - file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), - usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, - usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + String targetLabel = params.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} 本机部署到 ${params.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 '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + } + } + } + } finally { + cleanWs(deleteDirs: true, notFailBuild: true) + } } } } } } - post { - always { - cleanWs(deleteDirs: true, notFailBuild: true) - } - } } stage('部署单个服务') { @@ -213,7 +258,10 @@ pipeline { withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { try { sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"' - withEnv(["DEVELOPMENT_DEPLOY_LOCAL=${localDevelopmentDeploy}"]) { + withEnv([ + "DEVELOPMENT_DEPLOY_LOCAL=${localDevelopmentDeploy}", + "EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}" + ]) { withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) { sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" } diff --git a/README.md b/README.md index 884c9de44..0f2913388 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 柏悦头程平台 -本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。 +本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 @@ -12,20 +12,24 @@ - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。 +- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。 +- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 -- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。 +- `platform-bom`:统一管理仓库内公共依赖版本。 ## 本地构建 ```powershell mvn -DskipTests validate +node --test scripts/java-topology-contract.test.mjs mvn test ``` +GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。 + 2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 -本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。 +国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。 国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 @@ -41,4 +45,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com ## 仓库边界 -仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 +仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index bc91720f2..b36bad7b2 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -4,6 +4,7 @@ - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 +- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。 - `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。 ## 模块入口 @@ -14,7 +15,8 @@ | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.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 签发 | 登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience 并携带海外 API 所需权限快照;前端统一认证层负责按区域 API 自动选择 | +| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 | +| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | | 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` | diff --git a/auth/JWT-JWKS-CONTRACT.md b/auth/JWT-JWKS-CONTRACT.md new file mode 100644 index 000000000..d92044357 --- /dev/null +++ b/auth/JWT-JWKS-CONTRACT.md @@ -0,0 +1,76 @@ +# 国内与海外 JWT/JWKS 冻结契约 + +## 双轨边界 + +### 国内现有链路 + +- 登录、刷新、退出和代登录接口保持原契约,不修改 `TokenResponse`。 +- 签名算法保持 `HS256`,继续使用 `JWT_SECRET`。 +- Access Token 默认有效期保持 24 分钟,Refresh Token 默认有效期保持 7 天。 +- 业务服务继续使用国内 HMAC 验签与 Redis 会话校验,账号停用、退出和 Token 版本失效规则不变。 +- 国内 Token 保持现有 snake_case 声明,不增加海外 `aud`,也不要求配置 RSA/JWK。 + +### 海外尾程链路 + +- 海外能力默认关闭:`OVERSEAS_JWT_ENABLED=false`。 +- 已通过国内 HS256 与 Redis 校验的登录用户调用 `POST /api/auth/overseas/token`,兑换独立的海外短时 Token。 +- 海外 Token 使用 `RS256`,默认有效期 15 分钟,不签发 Refresh Token。 +- 尾程必须配置 `baiyue.security.jwt-mode=jwks` 和 `baiyue.security.session-mode=claims`,只做 JWKS 验签和本地安全投影校验,不创建或访问国内 Redis 会话校验器。 +- 海外功能未启用时,兑换接口返回“海外认证未启用”;JWKS Controller、海外签发服务和 `overseasJwtEncoder` 均不创建,Auth 仍可正常启动并签发国内 HS256 Token。 + +## 海外签发契约 + +- `issuer`:`auth-service` +- `audience`:`lastleg-api` +- 算法白名单:仅 `RS256` +- Access Token 默认有效期:15 分钟 +- 兑换接口:`POST /api/auth/overseas/token` +- JWKS 路径:`/api/auth/.well-known/jwks.json` +- Auth 直连地址:`http://{auth-host}:9100/api/auth/.well-known/jwks.json` +- JWKS HTTP 缓存:5 分钟,响应 `Cache-Control: public, max-age=300` + +海外 Token 的 JSON 声明名称和类型固定如下: + +| 声明 | JSON 类型 | 说明 | +|---|---|---| +| `sub` | string | 用户 ID 的字符串形式 | +| `jti` | string | Token 唯一标识 | +| `iat` | number | 签发时间 | +| `exp` | number | 过期时间 | +| `aud` | string array | 固定包含 `lastleg-api` | +| `userId` | number | 认证用户 ID | +| `tenantId` | number | 当前客户 ID;平台账号为平台作用域值 | +| `appCode` | string | `PLATFORM` 或 `MERCHANT` | +| `roles` | string array | 当前角色编码快照 | +| `authorities` | string array | 当前权限编码快照 | +| `permissionVersion` | number | IAM 单调权限版本 | + +海外 Token 从 V1 起只使用上述 camelCase 自定义声明,不读取国内 snake_case 声明。 + +## 海外配置 + +```yaml +baiyue: + security: + 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} +``` + +只有显式启用海外能力时才校验 `OVERSEAS_JWT_RSA_JWK_SET` 和 `OVERSEAS_JWT_CURRENT_KID`。启用后配置不合法必须启动失败,避免错误密钥签发。 + +## 密钥轮换 + +- JWK Set 只允许 RSA 密钥。 +- `OVERSEAS_JWT_CURRENT_KID` 必须指向唯一的当前签名私钥,JWT Header 写入该 `kid`。 +- 非当前密钥只能保留公钥;JWKS 只发布公钥,不得输出私钥参数。 +- 轮换时同时发布当前公钥和上一公钥,上一公钥至少保留 20 分钟,即 15 分钟最大 Token 生命周期加 5 分钟 JWKS 缓存。 +- 重叠期结束后移除上一公钥,禁止复用历史 `kid`。 + +## IAM 安全事件 + +安全事件的 `eventType`、版本、状态枚举和 payload 以 `iam/SECURITY-EVENT-AUDIT.md` 为唯一契约。尾程维护用户状态、客户状态和权限版本的本地投影,请求期间不反向同步访问国内 IAM。 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 4e7361ecf..1061534be 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -20,4 +20,13 @@ public class AuthorizationSnapshot { private String clientType; private Set roles; private Set permissions; + private Long permissionVersion; + private Long overseasWarehouseId; + + public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, + Long permissionVersion) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, null); + } } diff --git a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java index b78cc2138..c0bb9d5ed 100644 --- a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java +++ b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java @@ -25,5 +25,6 @@ public interface IamOnboardingClient { String legalPerson, String legalPhone, String contactEmail, String countryCode, String address) { } - record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus) { } + record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus) { } } diff --git a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java index 7362715cc..c301d1aff 100644 --- a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java +++ b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java @@ -1,6 +1,5 @@ package com.baiyue.auth.config; -import com.nimbusds.jose.jwk.source.ImmutableSecret; import com.baiyue.common.security.JwtAuthenticationFilter; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -10,14 +9,11 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.jose.jws.MacAlgorithm; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import java.nio.charset.StandardCharsets; /** * 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。 @@ -31,6 +27,7 @@ public class AuthSecurityConfig { "/api/auth/refresh", "/api/auth/logout", "/api/auth/impersonations/exchange", + "/api/auth/.well-known/jwks.json", "/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", @@ -40,13 +37,6 @@ public class AuthSecurityConfig { @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } - @Bean - 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)); - } - @Bean SecurityFilterChain authSecurityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter, diff --git a/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java new file mode 100644 index 000000000..acae75153 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java @@ -0,0 +1,22 @@ +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/OverseasJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java new file mode 100644 index 000000000..ed7d60faa --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java @@ -0,0 +1,37 @@ +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; +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) { + 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 密钥"); + } + if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) { + 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("海外非当前密钥只能配置公钥"); + } + return new NimbusJwtEncoder(new ImmutableJWKSet<>(set)); + } catch (java.text.ParseException exception) { + throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception); + } + } +} diff --git a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java new file mode 100644 index 000000000..62e173c7f --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java @@ -0,0 +1,10 @@ +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/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java index e021efea6..5901b1d0e 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -23,6 +23,8 @@ 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; /** @@ -38,6 +40,18 @@ 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()") 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 ead027f87..643bb1607 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -3,12 +3,16 @@ 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.*; @@ -18,11 +22,14 @@ import java.security.MessageDigest; /** 仅供可信内部服务调用的账号管理接口。 */ @RestController @RequestMapping("/internal/auth/accounts") +@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs") public class InternalAccountController { @Resource private AccountManagementService service; @Resource private AuthService authService; + @Resource + private OverseasSessionValidationService overseasSessionValidationService; @Value("${baiyue.security.internal-service-token}") private String configuredToken; @@ -64,6 +71,15 @@ 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( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @Valid @RequestBody OverseasSessionValidationDtos.Request request) { + verify(token); + return ApiResponse.success(overseasSessionValidationService.validate(request)); + } @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 new file mode 100644 index 000000000..f8fa9487e --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java @@ -0,0 +1,31 @@ +package com.baiyue.auth.controller; + +import com.baiyue.auth.config.OverseasJwtProperties; +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; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */ +@RestController +@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") +public class JwksController { + private final OverseasJwtProperties properties; + + public JwksController(OverseasJwtProperties properties) { this.properties = properties; } + + @GetMapping("/api/auth/.well-known/jwks.json") + @SecurityRequirements + @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/MerchantRegisterResponse.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java index 3c357b850..09ece63c9 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java +++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java @@ -5,5 +5,6 @@ public record MerchantRegisterResponse( Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus, String nextStep ) { } diff --git a/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java b/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java new file mode 100644 index 000000000..2ab9a5f3f --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java @@ -0,0 +1,29 @@ +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 eeb8d43ca..ac2206a4d 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java +++ b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java @@ -11,6 +11,10 @@ public record TokenResponse( @Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken, @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 = "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 ) { -} \ No newline at end of file +} 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 8760a7331..4f80622ee 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -23,6 +23,7 @@ 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 java.security.SecureRandom; import java.time.Instant; @@ -49,6 +50,8 @@ public class AuthService { private AuthorizationLoader authorizationLoader; @Resource private TokenSessionStore tokenSessionStore; + @Resource + private ObjectProvider overseasTokens; private final SecureRandom secureRandom = new SecureRandom(); @@ -108,7 +111,7 @@ public class AuthService { Instant issuedAt = Instant.now(); String jwtId = UUID.randomUUID().toString(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet claims = JwtClaimsSet.builder() + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() .issuer(properties.issuer()).audience(List.of(properties.audience())) .id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .subject(account.getId().toString()) @@ -125,8 +128,11 @@ public class AuthService { .claim("userId", account.getId()) .claim("tenantId", account.getDefaultTenantId()) .claim("appCode", account.getDefaultAppCode()) - .claim("permissionVersion", account.getTokenVersion()) - .build(); + .claim("permissionVersion", account.getTokenVersion()); + if (authorization.getOverseasWarehouseId() != null) { + claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); + } + JwtClaimsSet claims = claimsBuilder.build(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( signingHeader(), claims)).getTokenValue(); @@ -135,8 +141,13 @@ public class AuthService { String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), 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()); + rawRefresh, properties.refreshTokenTtl().toSeconds(), + overseas == null ? null : overseas.accessToken(), + overseas == null ? 0L : overseas.expiresIn()); } @Transactional diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java index 09dd998cd..d3fa0f65f 100644 --- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java +++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java @@ -103,7 +103,8 @@ public class MerchantRegistrationService { throw exception; } return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(), - onboarding.tenantStatus(), "LOGIN_AND_COMPLETE_QUALIFICATIONS"); + onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(), + "LOGIN_AND_COMPLETE_QUALIFICATIONS"); } private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) { diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java new file mode 100644 index 000000000..0f566a290 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java @@ -0,0 +1,38 @@ +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 new file mode 100644 index 000000000..4d274f63e --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -0,0 +1,82 @@ +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.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */ +@Service +@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") +public class OverseasTokenService { + private static final List OVERSEAS_AUTHORITY_PREFIXES = List.of( + "platform:overseas-warehouse:", + "platform:overseas-business:", + "platform:overseas-master-data:", + "lastleg:" + ); + + @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("roles", authorization.getRoles()) + .claim("authorities", overseasAuthorities(authorization.getPermissions())) + .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()); + } + + private Set overseasAuthorities(Set permissions) { + if (permissions == null || permissions.isEmpty()) return Set.of(); + return permissions.stream() + .filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream().anyMatch(permission::startsWith)) + .collect(Collectors.toUnmodifiableSet()); + } + + public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { } +} diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index 126c6e6c3..33fe66410 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -39,6 +39,13 @@ 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} baiyue.clients: iam: diff --git a/auth/src/main/resources/db/phase1-admin-accounts.sql b/auth/src/main/resources/db/phase1-admin-accounts.sql index cfa329c3d..130763a9f 100644 --- a/auth/src/main/resources/db/phase1-admin-accounts.sql +++ b/auth/src/main/resources/db/phase1-admin-accounts.sql @@ -1,4 +1,4 @@ --- Phase-one local administrator accounts. +-- Phase-one local platform administrator account. Merchant accounts are created through onboarding. -- Development-only initial password: ChangeMe123! -- The BCrypt hash is intentionally stored instead of plaintext. USE baiyue_auth; @@ -10,9 +10,7 @@ INSERT INTO auth_account ( operator_tenant_id, default_membership_id, default_app_code, deleted ) VALUES (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0), - (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) + '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), display_name = VALUES(display_name), diff --git a/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java b/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java index d0768d74c..7712cde85 100644 --- a/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java +++ b/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java @@ -72,6 +72,6 @@ class AuthorizationLoaderTest { private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) { return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode, - Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view")); + Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"), 1L); } } 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 9da820b4c..0b7440f31 100644 --- a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java @@ -15,8 +15,9 @@ class AuthTokenTtlConfigurationContractTest { Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8); assertThat(configuration) - .contains("access-token-ttl: ${ACCESS_TOKEN_TTL:15m}") + .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("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 new file mode 100644 index 000000000..65c871721 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java @@ -0,0 +1,29 @@ +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/controller/JwksControllerTest.java b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java new file mode 100644 index 000000000..53a673c72 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java @@ -0,0 +1,40 @@ +package com.baiyue.auth.controller; + +import com.baiyue.auth.config.OverseasJwtProperties; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.util.Base64URL; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwksControllerTest { + @Test + void publishesOnlyPublicKeyMaterialForCurrentAndPreviousKeys() throws Exception { + 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 body = new JwksController(properties).jwks().getBody(); + String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body); + + assertThat(published).contains("current", "previous", "\"n\"", "\"e\"") + .doesNotContain("\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\""); + } + + private RSAKey key(String kid, boolean privatePart) { + RSAKey.Builder builder = new RSAKey.Builder( + Base64URL.encode(BigInteger.valueOf(3233)), Base64URL.encode(BigInteger.valueOf(17))) + .keyID(kid).keyUse(KeyUse.SIGNATURE).algorithm(JWSAlgorithm.RS256); + if (privatePart) builder.privateExponent(Base64URL.encode(BigInteger.valueOf(2753))); + return builder.build(); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java b/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java new file mode 100644 index 000000000..7c698a724 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java @@ -0,0 +1,28 @@ +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/repository/InitialAccountSeedSqlContractTest.java b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java index 025bf6ec7..82620e90c 100644 --- a/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialAccountSeedSqlContractTest { @Test - void initializesOnlyThePlatformAndItTestAdministratorAccounts() throws Exception { + void initializesOnlyThePlatformAdministratorAccount() throws Exception { assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql") .getContentAsString(StandardCharsets.UTF_8)); assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); @@ -21,9 +21,10 @@ class InitialAccountSeedSqlContractTest { assertThat(sql) .contains("(1000001, 'platform_admin'") .contains("'平台系统管理员', 'ACTIVE'") - .contains("(1000002, 'merchant_admin'") - .contains("'IT 测试客户管理员', 'ACTIVE'") .contains("password_change_required") + .doesNotContain("merchant_admin") + .doesNotContain("IT_TEST") + .doesNotContain("(1000002, 'merchant_admin'") .doesNotContain("一期演示企业管理员"); } 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 0d8e3851b..11db26bf7 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -23,7 +23,10 @@ import java.time.Duration; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -34,6 +37,7 @@ 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) @@ -59,7 +63,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("PLATFORM"); account.setTokenVersion(1); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", - "WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list")); + "WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(repository.findByUsername("operator")).thenReturn(account); @@ -67,8 +71,10 @@ 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)); - service.login(new LoginRequest("operator", "secret")); + var response = service.login(new LoginRequest("operator", "secret")); ArgumentCaptor jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class); verify(jwtEncoder).encode(jwt.capture()); @@ -79,15 +85,76 @@ class AuthServiceSmallJwtTest { .containsEntry("appCode", "PLATFORM") .containsEntry("permissionVersion", 1) .containsEntry("membership_id", 84L) + .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(); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), - org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")), + org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), 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); + } + + @Test + void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() { + AuthAccount account = new AuthAccount(); + account.setId(52L); + account.setUsername("merchant"); + account.setAccountStatus("ACTIVE"); + account.setDefaultTenantId(10001L); + account.setOperatorTenantId(10001L); + account.setDefaultMembershipId(94L); + account.setDefaultAppCode("MERCHANT"); + account.setTokenVersion(3); + AuthorizationSnapshot snapshot = new AuthorizationSnapshot(52L, 10001L, 94L, "MERCHANT", + "WEB", Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 3L); + Jwt encoded = org.mockito.Mockito.mock(Jwt.class); + + when(tokenSessionStore.consume("refresh-old")).thenReturn( + new TokenSessionStore.RefreshSession(52L, "refresh-session")); + 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)); + + var response = service.refresh("refresh-old"); + + assertThat(response.accessToken()).isEqualTo("domestic-refreshed"); + assertThat(response.tokenType()).isEqualTo("Bearer"); + 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); + 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()), + org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), + org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); + verify(authorizationLoader).load(account); + verify(overseasTokenService).issue(account, snapshot); + } + + @Test + void consumedRefreshTokenCannotMintAnotherDomesticOrOverseasPair() { + when(tokenSessionStore.consume("refresh-consumed")).thenReturn(null); + + assertThatThrownBy(() -> service.refresh("refresh-consumed")) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class) + .hasMessageContaining("刷新令牌无效或已过期"); + + verify(repository, never()).findById(any()); + verify(jwtEncoder, never()).encode(any()); + verify(overseasTokenService, never()).issue(any(), any()); } @Test @@ -102,7 +169,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("MERCHANT"); account.setTokenVersion(2); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT", - "WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list")); + "WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"), 2L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket( "ticket-1", 43L, 10001L, 85L, 42L, 84L, "support"); diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java index 5a4541699..fd61c5925 100644 --- a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java @@ -35,7 +35,7 @@ class MerchantRegistrationServiceTest { void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); var result = service.register(new MerchantRegisterRequest( "registered_owner", "Password123", "注册人", "测试企业", "张三", @@ -56,13 +56,14 @@ class MerchantRegistrationServiceTest { assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus()); assertEquals("encoded-password", storedAccount.getValue().getPasswordHash()); assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep()); + assertEquals("PENDING", result.dimensionInitializationStatus()); } @Test void failedAuthInsertCompensatesTheIamRegistration() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any()); @@ -82,7 +83,7 @@ class MerchantRegistrationServiceTest { void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); TransactionSynchronizationManager.initSynchronization(); diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java new file mode 100644 index 000000000..b98b7b637 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java @@ -0,0 +1,77 @@ +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("WAREHOUSE_PICKER"), 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 new file mode 100644 index 000000000..805788918 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -0,0 +1,132 @@ +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 lastlegAuthority = "lastleg:pda:operate"; + String domesticAuthority = "platform:firstleg:order:list"; + AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", + Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, 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) + .containsEntry("roles", Set.of("WAREHOUSE_PICKER")) + .containsEntry("authorities", Set.of(authority, lastlegAuthority)) + .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); + assertThat(response.expiresIn()).isEqualTo(900); + } + + @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("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L); + AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", + Set.of("WAREHOUSE_PICKER"), 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("WAREHOUSE_PICKER"), 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/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index ec95783fb..bf9e87321 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -14,13 +14,19 @@ | 事件 ID 与业务键 | `EventIdentifiers.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | -| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/service/LoadingCompletedOutboxService.java` | +| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | +| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | +| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`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 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | +| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `package-info.java` | -| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java` | +| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.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/`、国内生产者、尾程消费者和全部契约测试。 - 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 - MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 +- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。 - 从父目录运行 `mvn -pl common-event-contract -am test`。 diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java index 57d173010..6e86f8374 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java @@ -22,8 +22,20 @@ public final class EventPayloadCanonicalizer { if (payload == null || payload.isNull()) { throw new IllegalArgumentException("事件负载不能为空"); } + return write(sort(payload)); + } + + /** 仅用于接收规范化数字规则上线前已经生成的历史摘要。 */ + public static byte[] canonicalizeLegacy(JsonNode payload) { + if (payload == null || payload.isNull()) { + throw new IllegalArgumentException("事件负载不能为空"); + } + return write(sortLegacy(payload)); + } + + private static byte[] write(JsonNode payload) { try { - return OBJECT_MAPPER.writeValueAsBytes(sort(payload)); + return OBJECT_MAPPER.writeValueAsBytes(payload); } catch (JsonProcessingException exception) { throw new IllegalArgumentException("事件负载无法规范化", exception); } @@ -43,6 +55,26 @@ public final class EventPayloadCanonicalizer { node.forEach(child -> result.add(sort(child))); return result; } + if (node.isNumber()) { + return com.fasterxml.jackson.databind.node.DecimalNode.valueOf(node.decimalValue().stripTrailingZeros()); + } + return node; + } + + private static JsonNode sortLegacy(JsonNode node) { + if (node.isObject()) { + var names = new java.util.ArrayList(); + node.fieldNames().forEachRemaining(names::add); + names.sort(String::compareTo); + var result = OBJECT_MAPPER.createObjectNode(); + names.forEach(name -> result.set(name, sortLegacy(node.get(name)))); + return result; + } + if (node.isArray()) { + var result = OBJECT_MAPPER.createArrayNode(); + node.forEach(child -> result.add(sortLegacy(child))); + return result; + } return node; } } 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 ccee66d60..029056ada 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 @@ -10,6 +10,9 @@ public final class EventTypes { public static final String ORDER_CANCELLED = "OrderCancelled"; public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted"; public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted"; + public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED = + "FirstLegContainerShippingNodeRecorded"; + public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; 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"; diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java new file mode 100644 index 000000000..a9a537c41 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java @@ -0,0 +1,47 @@ +package com.baiyue.common.event; + +import java.time.Instant; +import java.util.Map; + +/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ +public record FirstLegContainerShippingNodeRecordedV1( + String queryId, + String customerId, + String oceanBookingId, + String containerBookingId, + String containerNo, + String standardNode, + int nodeSequence, + Instant observedAt, + Instant actualAt, + String locationCode, + String locationName, + String providerCode, + String providerEventId, + String providerNodeCode, + String sourceReference, + String rawPayloadHash) { + private static final Map STANDARD_NODES = Map.of( + "DEPARTED_WAREHOUSE", 10, "AT_ORIGIN_PORT", 20, "LOADED_ON_VESSEL", 30, + "DEPARTED_ORIGIN_PORT", 40, "IN_TRANSIT", 50, "ARRIVED_DESTINATION_PORT", 60, + "DISCHARGED_FROM_VESSEL", 70, "PICKUP_READY", 80, "PICKED_UP", 90, + "EMPTY_RETURNED", 100); + + public FirstLegContainerShippingNodeRecordedV1 { + if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank() + || oceanBookingId == null || oceanBookingId.isBlank() + || containerBookingId == null || containerBookingId.isBlank() + || containerNo == null || containerNo.isBlank()) { + throw new IllegalArgumentException("船节点交接业务标识不能为空"); + } + if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode)) + || observedAt == null || actualAt == null) { + throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实"); + } + if (providerCode == null || providerCode.isBlank() || providerEventId == null + || providerEventId.isBlank() || providerNodeCode == null || providerNodeCode.isBlank() + || rawPayloadHash == null || !rawPayloadHash.matches("(?i)^[0-9a-f]{64}$")) { + throw new IllegalArgumentException("船节点交接供应商审计字段不完整"); + } + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java index d396416d8..7d8dd65bd 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java @@ -16,7 +16,37 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items) { + List items, String customerId, String orderType, List pallets, + String destinationType) { + public Order { + items = items == null ? List.of() : items; + orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; + pallets = pallets == null ? List.of() : pallets; + } + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items, String customerId, String orderType, List pallets) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); + } + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); + } } public record Item( @@ -27,6 +57,31 @@ public record FirstLegLoadingPlanCompletedV1( BigDecimal dimensionalWeightKg, BigDecimal volumeCbm, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaTrackingNo, - String shipmentNo, String shippingMark) { + String shipmentNo, String shippingMark, String palletNo, List cartonMarks) { + public Item { + cartonMarks = cartonMarks == null ? List.of() : cartonMarks; + } + public Item(String sourceInboundDetailId, String skuCode, + String productNameZh, String productNameEn, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, + BigDecimal dimensionalWeightKg, BigDecimal volumeCbm, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String fbaTrackingNo, + String shipmentNo, String shippingMark) { + this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount, + productQuantity, palletCount, grossWeightKg, netWeightKg, + dimensionalWeightKg, volumeCbm, destinationWarehouseId, + destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo, + shipmentNo, shippingMark, null, List.of()); + } } + + /** 一键代发入仓单的托盘冻结快照。 */ + public record PalletSnapshot(String palletNo, List skuPackages) {} + + /** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */ + public record SkuPackageSnapshot(String skuCode, String productName, + int packageCount, int productQuantity, + List cartonMarks) {} } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index 032cc427c..786ebae4c 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -3,23 +3,94 @@ package com.baiyue.common.event; import java.math.BigDecimal; import java.util.List; -/** 装柜完成交接快照,包含三类逐货物备注。 */ +/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */ public record FirstLegLoadingPlanCompletedV2( String loadingPlanId, String planNo, String containerBookingId, String containerNo, String cabinetNo, String warehouseId, String warehouseName, List orders) { - public record Order(String sourceInboundId, String rootInboundId, String sourceOrderId, - String sourceOrderNo, String rootOrderId, String inboundCode, - String destinationWarehouseId, String destinationWarehouseCode, - String destinationWarehouseName, int packageCount, Integer productQuantity, - Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, - BigDecimal volumeCbm, List items) {} - public record Item(String sourceInboundDetailId, String skuCode, String productNameZh, - String productNameEn, int packageCount, Integer productQuantity, - Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, - BigDecimal dimensionalWeightKg, BigDecimal volumeCbm, - String destinationWarehouseId, String destinationWarehouseCode, - String destinationWarehouseName, String fbaTrackingNo, String shipmentNo, - String shippingMark, String customerRemark, String internalRemark, - String warehouseRemark) {} + public record Order( + String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items, String customerId, String orderType, List pallets, + /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */ + String destinationType) { + public Order { + items = items == null ? List.of() : items; + orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; + pallets = pallets == null ? List.of() : pallets; + } + + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items, String customerId, String orderType, List pallets) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); + } + + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); + } + } + + public record Item( + String sourceInboundDetailId, String skuCode, + String productNameZh, String productNameEn, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, + BigDecimal dimensionalWeightKg, BigDecimal volumeCbm, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String fbaTrackingNo, + String shipmentNo, String shippingMark, + String customerRemark, String internalRemark, String warehouseRemark, + String palletNo, + /** 按实装箱数稳定随机冻结的头程箱号;数量与 packageCount 相等且均对应本明细 SKU。 */ + List cartonMarks) { + public Item { + cartonMarks = cartonMarks == null ? List.of() : cartonMarks; + } + + public Item(String sourceInboundDetailId, String skuCode, + String productNameZh, String productNameEn, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, + BigDecimal dimensionalWeightKg, BigDecimal volumeCbm, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String fbaTrackingNo, + String shipmentNo, String shippingMark, + String customerRemark, String internalRemark, String warehouseRemark) { + this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount, + productQuantity, palletCount, grossWeightKg, netWeightKg, + dimensionalWeightKg, volumeCbm, destinationWarehouseId, + destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo, + shipmentNo, shippingMark, customerRemark, internalRemark, warehouseRemark, + null, List.of()); + } + } + + /** 一键代发入仓单的托盘冻结快照。 */ + public record PalletSnapshot(String palletNo, List skuPackages) {} + + /** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */ + public record SkuPackageSnapshot(String skuCode, String productName, + int packageCount, int productQuantity, + List cartonMarks) {} } 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 new file mode 100644 index 000000000..3794f37ef --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java @@ -0,0 +1,18 @@ +package com.baiyue.common.event; + +import java.time.Instant; + +/** 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) { } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java new file mode 100644 index 000000000..a919d58c5 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java @@ -0,0 +1,16 @@ +package com.baiyue.common.event; + +import java.time.Instant; + +/** + * 海外仓柜级卸柜完成事实;国内订舱 ID 是稳定聚合与幂等标识,containerNo 用于核对物理柜号。 + * unloadingCompletedAt 是全柜 complete 时刻,不得使用签收拍照上传时刻代替。 + */ +public record OverseasContainerUnloadedV1( + String sourceContainerBookingId, + String containerNo, + Instant unloadingCompletedAt, + Instant unloadingPhotoUploadedAt, + Instant actualEmptyReportedAt +) { +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/EventPayloadCanonicalizerTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/EventPayloadCanonicalizerTest.java new file mode 100644 index 000000000..7d0f2938b --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/EventPayloadCanonicalizerTest.java @@ -0,0 +1,29 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class EventPayloadCanonicalizerTest { + private final ObjectMapper json = new ObjectMapper(); + + @Test + void equivalentNumberLexemesHaveTheSameCanonicalFormAndHash() throws Exception { + var scaled = json.readTree("{\"weight\":30.0,\"volume\":0.3150,\"quantity\":3}"); + var normalized = json.readTree("{\"quantity\":3.0,\"volume\":0.315,\"weight\":3E+1}"); + assertArrayEquals(EventPayloadCanonicalizer.canonicalize(scaled), EventPayloadCanonicalizer.canonicalize(normalized)); + assertEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(scaled)), + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(normalized))); + } + + @Test + void negativeZeroIsZeroAndLegacyHashRemainsAvailable() throws Exception { + var negativeZero = json.readTree("{\"value\":-0.000}"); + var zero = json.readTree("{\"value\":0}"); + assertArrayEquals(EventPayloadCanonicalizer.canonicalize(negativeZero), EventPayloadCanonicalizer.canonicalize(zero)); + assertNotEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(negativeZero)), + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(negativeZero))); + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java new file mode 100644 index 000000000..faaa767cb --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java @@ -0,0 +1,49 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FirstLegContainerShippingNodeRecordedV1Test { + @Test + void roundTripsLongBusinessIdsAndActualPickupReadyFact() throws Exception { + var payload = payload(); + var mapper = new ObjectMapper().findAndRegisterModules(); + + var restored = mapper.readValue(mapper.writeValueAsString(payload), + FirstLegContainerShippingNodeRecordedV1.class); + + assertThat(restored).isEqualTo(payload); + assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003"); + assertThat(restored.standardNode()).isEqualTo("PICKUP_READY"); + assertThat(restored.nodeSequence()).isEqualTo(80); + } + + @Test + void rejectsEstimatedOnlyOrIncompleteProviderFacts() { + assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( + "1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80, + Instant.parse("2026-08-25T01:00:00Z"), null, null, null, + "ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间"); + + assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( + "1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60, + Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"), + null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64))) + .isInstanceOf(IllegalArgumentException.class); + } + + private static FirstLegContainerShippingNodeRecordedV1 payload() { + return new FirstLegContainerShippingNodeRecordedV1( + "2088000000000000001", "2088000000000000002", "2088000000000000004", + "2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80, + Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"), + "USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE", + "ZIM:evt-1", "a".repeat(64)); + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java index 2c9fb34cb..95a6b394b 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java @@ -26,4 +26,38 @@ class FirstLegLoadingPlanCompletedV1Test { assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload); assertThat(json).contains("2088000000000000003", "sourceInboundDetailId"); } + + @Test + void dropshipPayloadKeepsMultiplePalletsAndCartonMarks() throws Exception { + var sku1 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot( + "SKU-1", "产品1", 2, 20, List.of("BOX-001", "BOX-002")); + var sku2 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot( + "SKU-2", "产品2", 1, 5, List.of("BOX-003")); + var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200", + "RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE, + BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP", + List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)), + new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2)))); + var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null, + "3", "海外仓", List.of(order)); + + String json = mapper.writeValueAsString(payload); + FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); + + assertThat(restored).isEqualTo(payload); + assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId"); + } + + @Test + void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception { + String json = """ + {"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1", + "warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100", + "destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]} + """; + + FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); + + assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS"); + } } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index 1a5ba5abb..d15900021 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -21,4 +21,49 @@ class FirstLegLoadingPlanCompletedV2Test { assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText()) .isEqualTo("仓库备注"); } + + @Test + void carriesDropshipPalletAndCartonSnapshots() throws Exception { + var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot( + "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002")); + var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku)); + var order = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, null, null, + 2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet)); + var payload = new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)); + + var json = new ObjectMapper().valueToTree(payload); + + assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11"); + assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP"); + assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); + } + + @Test + void carriesFrozenDestinationType() { + var order = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8", + 2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA"); + + var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); + + assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA"); + } + + @Test + void mapsEachOrdinaryCartonNumberToItsSku() { + var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2, + 20, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02")); + var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", + null, null, null, 2, 20, null, null, null, null, List.of(item)); + + var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); + + assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2"); + assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2); + } } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java new file mode 100644 index 000000000..0942ac072 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java @@ -0,0 +1,26 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasContainerUnloadedV1Test { + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception { + var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567", + Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); + + String json = mapper.writeValueAsString(payload); + + assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload); + assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt"); + assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt()); + assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt()); + } +} diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index 59eed2647..f91a255d8 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -13,10 +13,12 @@ | `baiyue.openapi` 配置项 | `src/main/java/com/baiyue/common/openapi/OpenApiProperties.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 长整型 OpenAPI 字符串契约 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` | +| PDA OpenAPI 分组 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` 的 `pda` GroupedOpenApi;固定匹配 `/api/pda/**`,合同测试 `src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java` | ## 修改与验证 - 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。 +- PDA Controller 必须进入独立 `pda` 分组;新增或改名 `/api/pda/**` 路径时同步检查分组合同和目标服务 OpenAPI,不得混入平台或商户分组。 - 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。 - 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。 - 从父目录运行 `mvn -pl common-openapi -am test`。 diff --git a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java index 3a5a42502..de69a8909 100644 --- a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java +++ b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java @@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration { .addOperationCustomizer(correlationHeaderCustomizer).build(); } + @Bean + GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) { + return GroupedOpenApi.builder().group("pda") + .displayName("PDA APIs / PDA 作业接口") + .pathsToMatch("/api/pda/**") + .addOperationCustomizer(correlationHeaderCustomizer).build(); + } + private Parameter header(String name, String description, boolean required) { return new HeaderParameter().name(name).description(description).required(required) .schema(new io.swagger.v3.oas.models.media.StringSchema()); diff --git a/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java b/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java new file mode 100644 index 000000000..dcf28b7df --- /dev/null +++ b/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java @@ -0,0 +1,17 @@ +package com.baiyue.common.openapi; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaOpenApiGroupContractTest { + @Test + void pdaGroupOnlyMatchesDeviceApis() { + BaiyueOpenApiAutoConfiguration configuration = new BaiyueOpenApiAutoConfiguration(); + var group = configuration.pdaApi(configuration.correlationHeaderCustomizer()); + + assertThat(group.getGroup()).isEqualTo("pda"); + assertThat(group.getPathsToMatch()).containsExactly("/api/pda/**"); + assertThat(group.getPathsToMatch()).noneMatch(path -> path.contains("internal")); + } +} diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index 5829c1000..e9e566096 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -12,7 +12,7 @@ | 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` | | JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | -| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` | +| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` | | 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | @@ -22,6 +22,7 @@ - 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。 - Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 +- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java index 61d675e30..e828d997b 100644 --- a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java +++ b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java @@ -9,11 +9,34 @@ import java.util.Set; public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, String username, String displayName, String appCode, String clientType, Set permissions, boolean impersonated, Long actorUserId, - Long actorMembershipId, boolean passwordChangeRequired) implements Principal { + Long actorMembershipId, boolean passwordChangeRequired, + Long overseasWarehouseId) implements Principal { public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, String username, String appCode, String clientType, Set permissions) { this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType, - permissions, false, null, null, false); + permissions, false, null, null, false, null); + } + public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, + String username, String appCode, String clientType, Set permissions, + boolean impersonated, Long actorUserId, Long actorMembershipId, + boolean passwordChangeRequired) { + this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType, + permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null); + } + public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, + String username, String displayName, String appCode, String clientType, + Set permissions, boolean impersonated, Long actorUserId, + Long actorMembershipId, boolean passwordChangeRequired) { + this(userId, tenantId, operatorTenantId, membershipId, username, displayName, appCode, clientType, + permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null); + } + public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, + String username, String appCode, String clientType, Set permissions, + boolean impersonated, Long actorUserId, Long actorMembershipId, + boolean passwordChangeRequired, Long overseasWarehouseId) { + this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType, + permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, + overseasWarehouseId); } public AuthenticatedUser { permissions = permissions == null ? Set.of() : Set.copyOf(permissions); diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java index 59260406e..c8dee758e 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java @@ -44,7 +44,8 @@ public class JwtClaimsMapper { Boolean.TRUE.equals(jwt.getClaim("impersonated")), longClaim(jwt, "actor_user_id"), longClaim(jwt, "actor_membership_id"), - Boolean.TRUE.equals(jwt.getClaim("password_change_required"))); + Boolean.TRUE.equals(jwt.getClaim("password_change_required")), + longClaim(jwt, "overseas_warehouse_id")); List authorities = permissions.stream() .map(SimpleGrantedAuthority::new).toList(); return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities); diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java index 3189cd48a..ce04f51be 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java @@ -81,6 +81,7 @@ class JwtClaimsMapperTest { .claim("actor_user_id", 42L) .claim("actor_membership_id", 43L) .claim("password_change_required", true) + .claim("overseas_warehouse_id", 900L) .build(); AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal(); @@ -88,5 +89,6 @@ class JwtClaimsMapperTest { assertThat(user.actorUserId()).isEqualTo(42L); assertThat(user.actorMembershipId()).isEqualTo(43L); assertThat(user.passwordChangeRequired()).isTrue(); + assertThat(user.overseasWarehouseId()).isEqualTo(900L); } } diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 23b2f7310..019c951df 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -11,6 +11,9 @@ |---|---| | 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` | | 请求 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 服务传递 | +| 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` 标记 | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | @@ -23,5 +26,8 @@ - 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。 - 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 - 修改 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。 +- 同步 RestClient 出站统一覆盖 `X-Request-Id`、`X-Trace-Id`、`traceparent`,从当前 MDC 生成 child span;MDC 缺失或非法时安全新建 trace。不得从业务参数或远端响应头建立上下文,生产代码应使用 Spring 管理的 `RestClient.Builder`,原始 Builder 必须显式安装同一拦截器。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/pom.xml b/common-web/pom.xml index fa892aee9..3a5a14627 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -39,6 +39,11 @@ io.micrometer micrometer-registry-prometheus + + io.github.openfeign + feign-core + true + org.springframework.boot spring-boot-starter-test diff --git a/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java b/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java index 976ab0b30..a38dfaf80 100644 --- a/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java +++ b/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java @@ -9,8 +9,11 @@ import org.slf4j.MDC; public final class CorrelationContext { public static final String REQUEST_ID = "requestId"; public static final String TRACE_ID = "traceId"; + public static final String SPAN_ID = "spanId"; + public static final String TRACE_PARENT = "traceparent"; public static final String REQUEST_ID_HEADER = "X-Request-Id"; public static final String TRACE_ID_HEADER = "X-Trace-Id"; + public static final String TRACE_PARENT_HEADER = TraceParent.HEADER; private CorrelationContext() {} @@ -22,6 +25,14 @@ public final class CorrelationContext { return value(request, TRACE_ID); } + public static String spanId(HttpServletRequest request) { + return value(request, SPAN_ID); + } + + public static String traceParent(HttpServletRequest request) { + return value(request, TRACE_PARENT); + } + private static String value(HttpServletRequest request, String key) { Object attribute = request.getAttribute(key); return attribute == null ? MDC.get(key) : attribute.toString(); diff --git a/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java b/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java index b08f7c832..f9e39379d 100644 --- a/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java +++ b/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Enumeration; import java.util.UUID; import java.util.regex.Pattern; @@ -25,24 +26,60 @@ public class CorrelationIdFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false); - String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true); + String requestId = requestId(request); + TraceParent traceParent = traceParent(request); + String traceId = traceParent.traceId(); request.setAttribute(CorrelationContext.REQUEST_ID, requestId); request.setAttribute(CorrelationContext.TRACE_ID, traceId); + request.setAttribute(CorrelationContext.SPAN_ID, traceParent.parentId()); + request.setAttribute(CorrelationContext.TRACE_PARENT, traceParent.value()); response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId); response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId); + response.setHeader(CorrelationContext.TRACE_PARENT_HEADER, traceParent.value()); try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId); - MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) { + MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId); + MDC.MDCCloseable ignoredSpan = MDC.putCloseable(CorrelationContext.SPAN_ID, traceParent.parentId())) { filterChain.doFilter(request, response); } } - private String acceptedOrGenerated(String candidate, boolean compact) { - if (candidate != null && SAFE_ID.matcher(candidate).matches()) { - return candidate; + private String requestId(HttpServletRequest request) { + HeaderValue header = singleHeader(request, CorrelationContext.REQUEST_ID_HEADER, 128); + if (header.valid() && SAFE_ID.matcher(header.value()).matches()) { + return header.value(); + } + return UUID.randomUUID().toString(); + } + + private TraceParent traceParent(HttpServletRequest request) { + HeaderValue standard = singleHeader(request, CorrelationContext.TRACE_PARENT_HEADER, + TraceParent.HEADER_LENGTH); + if (standard.present()) { + if (standard.valid()) { + return TraceParent.parse(standard.value()).map(TraceParent::childOf) + .orElseGet(() -> TraceParent.newRoot(null)); + } + return TraceParent.newRoot(null); + } + + HeaderValue legacy = singleHeader(request, CorrelationContext.TRACE_ID_HEADER, 32); + return TraceParent.newRoot(legacy.valid() ? legacy.value() : null); + } + + private HeaderValue singleHeader(HttpServletRequest request, String name, int maxLength) { + Enumeration values = request.getHeaders(name); + if (values == null || !values.hasMoreElements()) { + return HeaderValue.missing(); + } + String value = values.nextElement(); + boolean duplicate = values.hasMoreElements(); + return new HeaderValue(true, !duplicate && value != null && value.length() <= maxLength, value); + } + + private record HeaderValue(boolean present, boolean valid, String value) { + private static HeaderValue missing() { + return new HeaderValue(false, false, null); } - String id = UUID.randomUUID().toString(); - return compact ? id.replace("-", "") : id; } } diff --git a/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java new file mode 100644 index 000000000..e4223e528 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java @@ -0,0 +1,18 @@ +package com.baiyue.common.web; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** Enables synchronous Feign trace propagation without making Feign a required dependency. */ +@AutoConfiguration(afterName = "org.springframework.cloud.openfeign.FeignAutoConfiguration") +@ConditionalOnClass(name = "feign.RequestInterceptor") +public class FeignTracePropagationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(FeignTraceRequestInterceptor.class) + FeignTraceRequestInterceptor feignTraceRequestInterceptor() { + return new FeignTraceRequestInterceptor(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java b/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java new file mode 100644 index 000000000..88303c069 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java @@ -0,0 +1,41 @@ +package com.baiyue.common.web; + +import feign.RequestInterceptor; +import feign.RequestTemplate; +import org.slf4j.MDC; +import org.springframework.core.Ordered; + +import java.util.regex.Pattern; + +/** Propagates the trusted synchronous request trace context to every Feign invocation. */ +public final class FeignTraceRequestInterceptor implements RequestInterceptor, Ordered { + private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + @Override + public void apply(RequestTemplate template) { + removeManagedHeaders(template); + + String requestId = MDC.get(CorrelationContext.REQUEST_ID); + if (requestId != null && SAFE_REQUEST_ID.matcher(requestId).matches()) { + template.header(CorrelationContext.REQUEST_ID_HEADER, requestId); + } + + String traceId = MDC.get(CorrelationContext.TRACE_ID); + if (TraceParent.validLegacyTraceId(traceId)) { + TraceParent outbound = TraceParent.outboundChild(traceId); + template.header(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()); + template.header(CorrelationContext.TRACE_PARENT_HEADER, outbound.value()); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + private void removeManagedHeaders(RequestTemplate template) { + template.removeHeader(CorrelationContext.REQUEST_ID_HEADER); + template.removeHeader(CorrelationContext.TRACE_ID_HEADER); + template.removeHeader(CorrelationContext.TRACE_PARENT_HEADER); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java index f0c42012b..652028ab7 100644 --- a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java +++ b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java @@ -21,6 +21,8 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import org.springframework.web.server.ResponseStatusException; import java.util.LinkedHashMap; import java.util.Map; @@ -79,6 +81,21 @@ public class GlobalExceptionHandler { return failure(ResultCode.FORBIDDEN, ResultCode.FORBIDDEN.message(), request); } + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus( + ResponseStatusException exception, HttpServletRequest request) { + ResultCode code = resultCode(exception.getStatusCode().value()); + String message = exception.getReason() == null || exception.getReason().isBlank() + ? code.message() : exception.getReason(); + return failure(code, message, request); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResourceFound( + NoResourceFoundException exception, HttpServletRequest request) { + return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleUnexpected(Exception exception, HttpServletRequest request) { log.error("Unhandled request failure requestId={} traceId={}", @@ -123,4 +140,18 @@ public class GlobalExceptionHandler { meterRegistry.counter("baiyue.api.business.failures", "code", code.code()).increment(); } } + + private ResultCode resultCode(int status) { + return switch (status) { + case 400 -> ResultCode.BAD_REQUEST; + case 401 -> ResultCode.UNAUTHORIZED; + case 403 -> ResultCode.FORBIDDEN; + case 404 -> ResultCode.NOT_FOUND; + case 409 -> ResultCode.CONFLICT; + case 422 -> ResultCode.VALIDATION_FAILED; + case 429 -> ResultCode.TOO_MANY_REQUESTS; + case 503 -> ResultCode.SERVICE_UNAVAILABLE; + default -> ResultCode.INTERNAL_ERROR; + }; + } } diff --git a/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java b/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java new file mode 100644 index 000000000..bbd1be9a4 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java @@ -0,0 +1,40 @@ +package com.baiyue.common.web; + +import org.slf4j.MDC; + +import java.util.UUID; +import java.util.regex.Pattern; + +/** Immutable standard headers for one synchronous outbound child span. */ +public record OutboundTraceHeaders(String requestId, String traceId, String traceParent) { + private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + /** Builds a child span from the current MDC, safely starting a new trace when absent/invalid. */ + public static OutboundTraceHeaders fromCurrentOrNew() { + String requestId = safeRequestId(MDC.get(CorrelationContext.REQUEST_ID)); + String traceId = TraceParent.normalizeTrustedTraceId(MDC.get(CorrelationContext.TRACE_ID)) + .orElseGet(() -> TraceParent.newRoot(null).traceId()); + return child(requestId, traceId); + } + + /** Builds a local child from a trusted persisted trace value, or safely starts a new trace. */ + public static OutboundTraceHeaders fromTrustedTraceOrNew(String trustedTraceId) { + String traceId = TraceParent.normalizeTrustedTraceId(trustedTraceId) + .orElseGet(() -> TraceParent.newRoot(null).traceId()); + return child(UUID.randomUUID().toString(), traceId); + } + + public String spanId() { + return TraceParent.parse(traceParent).orElseThrow().parentId(); + } + + private static OutboundTraceHeaders child(String requestId, String traceId) { + TraceParent child = TraceParent.outboundChild(traceId); + return new OutboundTraceHeaders(requestId, child.traceId(), child.value()); + } + + private static String safeRequestId(String value) { + return value != null && SAFE_REQUEST_ID.matcher(value).matches() + ? value : UUID.randomUUID().toString(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java new file mode 100644 index 000000000..0fcf7ad9f --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java @@ -0,0 +1,24 @@ +package com.baiyue.common.web; + +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.core.Ordered; +import org.springframework.web.client.RestClient; + +/** Installs the trace interceptor after ordinary RestClient customizers. */ +public final class RestClientTraceCustomizer implements RestClientCustomizer, Ordered { + private final RestClientTraceInterceptor interceptor; + + public RestClientTraceCustomizer(RestClientTraceInterceptor interceptor) { + this.interceptor = interceptor; + } + + @Override + public void customize(RestClient.Builder builder) { + builder.requestInterceptor(interceptor); + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java new file mode 100644 index 000000000..488cd808f --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java @@ -0,0 +1,21 @@ +package com.baiyue.common.web; + +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; + +/** Applies the trusted synchronous trace context to Spring RestClient requests. */ +public final class RestClientTraceInterceptor implements ClientHttpRequestInterceptor { + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew(); + request.getHeaders().set(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId()); + request.getHeaders().set(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()); + request.getHeaders().set(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent()); + return execution.execute(request, body); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java new file mode 100644 index 000000000..6acccad5d --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java @@ -0,0 +1,25 @@ +package com.baiyue.common.web; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestClient; + +/** Adds trace propagation to Spring-managed RestClient builders. */ +@AutoConfiguration(afterName = "org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration") +@ConditionalOnClass({RestClient.class, RestClientCustomizer.class}) +public class RestClientTracePropagationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(RestClientTraceInterceptor.class) + RestClientTraceInterceptor restClientTraceInterceptor() { + return new RestClientTraceInterceptor(); + } + + @Bean + RestClientTraceCustomizer baiyueTraceRestClientCustomizer(RestClientTraceInterceptor interceptor) { + return new RestClientTraceCustomizer(interceptor); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java new file mode 100644 index 000000000..88ae5829f --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java @@ -0,0 +1,111 @@ +package com.baiyue.common.web; + +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.Optional; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Strict W3C {@code traceparent} version 00 value. + * + *

This type deliberately accepts only the currently frozen version-00 shape. Unsupported, + * ambiguous or malformed input must start a new trace instead of being partially trusted.

+ */ +public record TraceParent(String traceId, String parentId, String traceFlags) { + public static final String HEADER = "traceparent"; + public static final int HEADER_LENGTH = 55; + + private static final Pattern VERSION_00 = Pattern.compile( + "00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})"); + private static final Pattern LEGACY_TRACE_ID = Pattern.compile("[0-9A-Fa-f]{32}"); + private static final String ZERO_TRACE_ID = "0".repeat(32); + private static final String ZERO_PARENT_ID = "0".repeat(16); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final HexFormat HEX = HexFormat.of(); + + public TraceParent { + if (!validTraceId(traceId) || !validParentId(parentId) + || traceFlags == null || !traceFlags.matches("[0-9a-f]{2}")) { + throw new IllegalArgumentException("Invalid W3C traceparent components"); + } + } + + /** Parses one exact, lowercase W3C version-00 header value. */ + public static Optional parse(String value) { + if (value == null || value.length() != HEADER_LENGTH) { + return Optional.empty(); + } + Matcher matcher = VERSION_00.matcher(value); + if (!matcher.matches() || !validTraceId(matcher.group(1)) || !validParentId(matcher.group(2))) { + return Optional.empty(); + } + return Optional.of(new TraceParent(matcher.group(1), matcher.group(2), matcher.group(3))); + } + + /** Creates the local inbound context for a valid remote parent. */ + public static TraceParent childOf(TraceParent remoteParent) { + return new TraceParent(remoteParent.traceId(), randomNonZero(8), remoteParent.traceFlags()); + } + + /** Creates a new root context, optionally reusing a valid legacy trace ID. */ + public static TraceParent newRoot(String legacyTraceId) { + String traceId = validLegacyTraceId(legacyTraceId) + ? legacyTraceId.toLowerCase(java.util.Locale.ROOT) : randomNonZero(16); + return new TraceParent(traceId, randomNonZero(8), "00"); + } + + /** Creates a sampled outbound child for a trusted current trace ID. */ + public static TraceParent outboundChild(String currentTraceId) { + if (!validLegacyTraceId(currentTraceId)) { + throw new IllegalArgumentException("Current trace ID is invalid"); + } + return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01"); + } + + /** + * Normalizes a trace ID from a trusted persisted/internal context. + * + *

Unlike an untrusted HTTP header, a canonical UUID is accepted and compacted because + * existing event envelopes historically persisted UUID trace IDs.

+ */ + public static Optional normalizeTrustedTraceId(String value) { + if (validLegacyTraceId(value)) { + return Optional.of(value.toLowerCase(Locale.ROOT)); + } + if (value != null && value.matches( + "[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}")) { + String compact = value.replace("-", "").toLowerCase(Locale.ROOT); + return validLegacyTraceId(compact) ? Optional.of(compact) : Optional.empty(); + } + return Optional.empty(); + } + + public static boolean validLegacyTraceId(String value) { + return value != null && LEGACY_TRACE_ID.matcher(value).matches() + && !ZERO_TRACE_ID.equalsIgnoreCase(value); + } + + public String value() { + return "00-" + traceId + '-' + parentId + '-' + traceFlags; + } + + private static boolean validTraceId(String value) { + return value != null && value.matches("[0-9a-f]{32}") && !ZERO_TRACE_ID.equals(value); + } + + private static boolean validParentId(String value) { + return value != null && value.matches("[0-9a-f]{16}") && !ZERO_PARENT_ID.equals(value); + } + + private static String randomNonZero(int byteCount) { + byte[] bytes = new byte[byteCount]; + String value; + do { + RANDOM.nextBytes(bytes); + value = HEX.formatHex(bytes); + } while (value.chars().allMatch(character -> character == '0')); + return value; + } +} 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 66426422f..2d417843c 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 @@ -1,2 +1,4 @@ com.baiyue.common.web.LongIdJacksonConfiguration com.baiyue.common.web.CommonWebAutoConfiguration +com.baiyue.common.web.FeignTracePropagationAutoConfiguration +com.baiyue.common.web.RestClientTracePropagationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java index bab2952df..08739af98 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java @@ -1,6 +1,8 @@ package com.baiyue.common.web; import org.junit.jupiter.api.Test; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -16,6 +18,8 @@ class CommonWebAutoConfigurationTest { contextRunner.run(context -> { assertThat(context).hasSingleBean(GlobalExceptionHandler.class); assertThat(context).hasSingleBean(CorrelationIdFilter.class); + assertThat(CorrelationIdFilter.class.getAnnotation(Order.class).value()) + .isEqualTo(Ordered.HIGHEST_PRECEDENCE); }); } diff --git a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java index 08bb2e583..67d4bd3be 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java @@ -6,6 +6,8 @@ import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import java.io.IOException; + import static org.assertj.core.api.Assertions.assertThat; @DisplayName("请求链路标识过滤功能") @@ -13,27 +15,87 @@ class CorrelationIdFilterTest { private final CorrelationIdFilter filter = new CorrelationIdFilter(); @Test - void preservesSafeIncomingIds() throws Exception { + void validTraceParentWinsAndMapsToLegacyTraceIdAndMdc() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123"); - request.addHeader(CorrelationContext.TRACE_ID_HEADER, "trace-123"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); MockHttpServletResponse response = new MockHttpServletResponse(); - filter.doFilter(request, response, new MockFilterChain()); + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilter(request, response, chain); assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123"); - assertThat(request.getAttribute(CorrelationContext.TRACE_ID)).isEqualTo("trace-123"); + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000"); + assertThat(org.slf4j.MDC.get(CorrelationContext.TRACE_ID)).isNull(); + assertThat(org.slf4j.MDC.get(CorrelationContext.SPAN_ID)).isNull(); } @Test - void replacesUnsafeIncomingIds() throws Exception { + void fallsBackToValidLegacyTraceIdOnlyWhenTraceParentIsMissing() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "invalid value\n"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "ABCDEF0123456789ABCDEF0123456789"); MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, new MockFilterChain()); + + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .isEqualTo("abcdef0123456789abcdef0123456789"); + assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00"); + } + + @Test + void invalidTraceParentStartsNewTraceWithoutTrustingLegacyTraceId() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-00000000000000000000000000000000-00f067aa0ba902b7-01"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .matches("[0-9a-f]{32}") + .isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .isNotEqualTo("00000000000000000000000000000000"); + } + + @Test + void rejectsDuplicateAndOversizedHeaders() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "a".repeat(129)); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)) .matches("[0-9a-f-]{36}"); assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) - .matches("[0-9a-f]{32}"); + .matches("[0-9a-f]{32}") + .isNotEqualTo("4bf92f3577b34da6a3ce929d0e0e4736") + .isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } + + private static final class CapturingFilterChain extends MockFilterChain { + private String traceId; + private String spanId; + + @Override + public void doFilter(jakarta.servlet.ServletRequest request, + jakarta.servlet.ServletResponse response) throws IOException { + traceId = org.slf4j.MDC.get(CorrelationContext.TRACE_ID); + spanId = org.slf4j.MDC.get(CorrelationContext.SPAN_ID); + } } } diff --git a/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java new file mode 100644 index 000000000..2aeec044f --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.common.web; + +import feign.RequestInterceptor; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class FeignTracePropagationAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FeignTracePropagationAutoConfiguration.class)); + + @Test + void registersTraceInterceptorWhenFeignIsPresent() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class); + assertThat(context.getBeansOfType(RequestInterceptor.class)).hasSize(1); + }); + } + + @Test + void doesNotLoadFeignConfigurationWhenFeignIsAbsent() { + contextRunner.withClassLoader(new FilteredClassLoader(RequestInterceptor.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean("feignTraceRequestInterceptor"); + }); + } + + @Test + void backsOffOnlyForAnApplicationProvidedTraceInterceptor() { + contextRunner.withBean(FeignTraceRequestInterceptor.class, FeignTraceRequestInterceptor::new) + .run(context -> assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class)); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java b/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java new file mode 100644 index 000000000..d87b3d445 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java @@ -0,0 +1,80 @@ +package com.baiyue.common.web; + +import feign.RequestTemplate; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.core.Ordered; + +import static org.assertj.core.api.Assertions.assertThat; + +class FeignTraceRequestInterceptorTest { + private final FeignTraceRequestInterceptor interceptor = new FeignTraceRequestInterceptor(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void runsLastSoTrustedContextHasFinalHeaderAuthority() { + assertThat(interceptor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE); + } + + @Test + void overwritesCallerHeadersFromTrustedMdcAndCreatesNewSampledSpan() { + MDC.put(CorrelationContext.REQUEST_ID, "req-trusted-123"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + RequestTemplate template = templateWithSpoofedHeaders(); + + interceptor.apply(template); + + assertThat(template.headers().get(CorrelationContext.REQUEST_ID_HEADER)) + .containsExactly("req-trusted-123"); + assertThat(template.headers().get(CorrelationContext.TRACE_ID_HEADER)) + .containsExactly("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(template.headers().get(CorrelationContext.TRACE_PARENT_HEADER)) + .singleElement().asString() + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void createsDifferentNonZeroSpanForEachSynchronousInvocation() { + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + RequestTemplate first = new RequestTemplate(); + RequestTemplate second = new RequestTemplate(); + + interceptor.apply(first); + interceptor.apply(second); + + assertThat(singleHeader(first, CorrelationContext.TRACE_PARENT_HEADER)) + .isNotEqualTo(singleHeader(second, CorrelationContext.TRACE_PARENT_HEADER)); + } + + @Test + void stripsManagedCallerHeadersWhenTrustedContextIsMissingOrInvalid() { + MDC.put(CorrelationContext.REQUEST_ID, "invalid request id"); + MDC.put(CorrelationContext.TRACE_ID, "not-a-w3c-trace-id"); + RequestTemplate template = templateWithSpoofedHeaders(); + + interceptor.apply(template); + + assertThat(template.headers()).doesNotContainKeys( + CorrelationContext.REQUEST_ID_HEADER, + CorrelationContext.TRACE_ID_HEADER, + CorrelationContext.TRACE_PARENT_HEADER); + } + + private RequestTemplate templateWithSpoofedHeaders() { + RequestTemplate template = new RequestTemplate(); + template.header(CorrelationContext.REQUEST_ID_HEADER, "req-business-parameter"); + template.header(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + template.header(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + return template; + } + + private String singleHeader(RequestTemplate template, String name) { + return template.headers().get(name).iterator().next(); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java index 5f44b0d87..c932c7d96 100644 --- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -12,7 +12,10 @@ import org.springframework.validation.BindException; import org.springframework.validation.FieldError; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; import java.lang.reflect.Method; @@ -87,6 +90,32 @@ class GlobalExceptionHandlerTest { .tag("code", ResultCode.CONFLICT.code()).counter().count()).isEqualTo(1.0); } + @Test + void responseStatusConflictMapsToCanonicalConflictInsteadOfInternalError() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(CorrelationContext.REQUEST_ID, "stale-version"); + + var response = handler.handleResponseStatus( + new ResponseStatusException(HttpStatus.CONFLICT, "Order was changed; refresh and retry"), request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code()); + assertThat(response.getBody().message()).isEqualTo("Order was changed; refresh and retry"); + assertThat(response.getBody().requestId()).isEqualTo("stale-version"); + } + + @Test + void missingControllerPathMapsToNotFoundInsteadOfInternalError() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + var response = handler.handleNoResourceFound( + new NoResourceFoundException(org.springframework.http.HttpMethod.GET, + "api/platform/v1/last-leg/dispatches"), request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code()); + } + private void paging(long page) { } } diff --git a/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java b/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java new file mode 100644 index 000000000..c0f3c006a --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java @@ -0,0 +1,41 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboundTraceHeadersTest { + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void currentContextCreatesAValidChildWithoutUsingBusinessValues() { + MDC.put(CorrelationContext.REQUEST_ID, "req-trusted"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + + OutboundTraceHeaders headers = OutboundTraceHeaders.fromCurrentOrNew(); + + assertThat(headers.requestId()).isEqualTo("req-trusted"); + assertThat(headers.traceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(headers.traceParent()) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void canonicalPersistedUuidIsCompactedAndInvalidValueStartsNewTrace() { + OutboundTraceHeaders uuid = OutboundTraceHeaders.fromTrustedTraceOrNew( + "550e8400-e29b-41d4-a716-446655440000"); + OutboundTraceHeaders invalid = OutboundTraceHeaders.fromTrustedTraceOrNew( + "ORDER:customer-99:booking-123"); + + assertThat(uuid.traceId()).isEqualTo("550e8400e29b41d4a716446655440000"); + assertThat(invalid.traceId()).matches("[0-9a-f]{32}") + .isNotEqualTo("ORDER:customer-99:booking-123"); + assertThat(invalid.requestId()).matches("[0-9a-f-]{36}"); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java b/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java new file mode 100644 index 000000000..b8c6e74e7 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java @@ -0,0 +1,60 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class RestClientTraceInterceptorTest { + private final RestClientTraceInterceptor interceptor = new RestClientTraceInterceptor(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void trustedContextOverwritesAllCallerProvidedTraceHeaders() throws Exception { + MDC.put(CorrelationContext.REQUEST_ID, "req-rest-client"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + MockClientHttpRequest request = new MockClientHttpRequest(); + request.getHeaders().add(CorrelationContext.REQUEST_ID_HEADER, "business-request-id"); + request.getHeaders().add(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + request.getHeaders().add(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + AtomicReference captured = new AtomicReference<>(); + + interceptor.intercept(request, new byte[0], (outbound, body) -> { + captured.set(outbound.getHeaders()); + return new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK); + }); + + assertThat(captured.get().getValuesAsList(CorrelationContext.REQUEST_ID_HEADER)) + .containsExactly("req-rest-client"); + assertThat(captured.get().getValuesAsList(CorrelationContext.TRACE_ID_HEADER)) + .containsExactly("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(captured.get().getFirst(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void missingOrInvalidMdcSafelyCreatesNewStandardHeaders() throws Exception { + MDC.put(CorrelationContext.TRACE_ID, "business-order-123"); + MockClientHttpRequest request = new MockClientHttpRequest(); + + interceptor.intercept(request, new byte[0], (outbound, body) -> + new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK)); + + assertThat(request.getHeaders().getFirst(CorrelationContext.REQUEST_ID_HEADER)) + .matches("[0-9a-f-]{36}"); + assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_ID_HEADER)) + .matches("[0-9a-f]{32}").isNotEqualTo("business-order-123"); + assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-[0-9a-f]{32}-[0-9a-f]{16}-01"); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java new file mode 100644 index 000000000..f2edb83d2 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.core.Ordered; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class RestClientTracePropagationAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(RestClientTracePropagationAutoConfiguration.class)); + + @Test + void registersInterceptorAndIndependentCustomizer() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(RestClientTraceInterceptor.class); + assertThat(context).hasBean("baiyueTraceRestClientCustomizer"); + assertThat(context.getBeansOfType(RestClientCustomizer.class)).hasSize(1); + RestClientTraceCustomizer customizer = context.getBean(RestClientTraceCustomizer.class); + assertThat(customizer.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE); + RestClient.Builder builder = mock(RestClient.Builder.class); + customizer.customize(builder); + verify(builder).requestInterceptor(context.getBean(RestClientTraceInterceptor.class)); + }); + } + + @Test + void reusesApplicationProvidedTraceInterceptor() { + contextRunner.withBean(RestClientTraceInterceptor.class, RestClientTraceInterceptor::new) + .run(context -> assertThat(context).hasSingleBean(RestClientTraceInterceptor.class)); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java new file mode 100644 index 000000000..e67870a1c --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java @@ -0,0 +1,62 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TraceParentTest { + + @Test + void parsesCanonicalVersion00AndCreatesLocalChild() { + TraceParent remote = TraceParent.parse( + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").orElseThrow(); + + TraceParent local = TraceParent.childOf(remote); + + assertThat(local.traceId()).isEqualTo(remote.traceId()); + assertThat(local.parentId()).matches("[0-9a-f]{16}") + .isNotEqualTo(remote.parentId()) + .isNotEqualTo("0000000000000000"); + assertThat(local.traceFlags()).isEqualTo("01"); + assertThat(local.value()).hasSize(TraceParent.HEADER_LENGTH); + } + + @Test + void rejectsNonCanonicalUnsupportedAndZeroIdentifiers() { + assertThat(TraceParent.parse( + "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01")).isEmpty(); + assertThat(TraceParent.parse( + "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")).isEmpty(); + assertThat(TraceParent.parse( + "00-00000000000000000000000000000000-00f067aa0ba902b7-01")).isEmpty(); + assertThat(TraceParent.parse( + "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01")).isEmpty(); + assertThat(TraceParent.parse("x".repeat(10_000))).isEmpty(); + } + + @Test + void createsCanonicalNonZeroRootAndNormalizesLegacyHex() { + TraceParent root = TraceParent.newRoot("ABCDEF0123456789ABCDEF0123456789"); + + assertThat(root.traceId()).isEqualTo("abcdef0123456789abcdef0123456789"); + assertThat(root.parentId()).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000"); + assertThat(root.value()).matches( + "00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00"); + } + + @Test + void createsSampledOutboundChildForTrustedCurrentTrace() { + TraceParent child = TraceParent.outboundChild("4BF92F3577B34DA6A3CE929D0E0E4736"); + + assertThat(child.value()).matches( + "00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void trustedPersistedUuidNormalizationDoesNotBroadenHttpParsing() { + assertThat(TraceParent.normalizeTrustedTraceId("550E8400-E29B-41D4-A716-446655440000")) + .contains("550e8400e29b41d4a716446655440000"); + assertThat(TraceParent.parse( + "00-550E8400E29B41D4A716446655440000-00f067aa0ba902b7-01")).isEmpty(); + } +} diff --git a/config/nacos/cn/auth-service.yml b/config/nacos/cn/auth-service.yml index fd913df2e..891768268 100644 --- a/config/nacos/cn/auth-service.yml +++ b/config/nacos/cn/auth-service.yml @@ -6,6 +6,12 @@ spring: driver-class-name: com.mysql.cj.jdbc.Driver baiyue: security: - audience: ${JWT_AUDIENCE:lastleg-api} - access-token-ttl: ${ACCESS_TOKEN_TTL:15m} + 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 index a5fbba087..5b81ac297 100644 --- a/config/nacos/cn/event-bridge-service.yml +++ b/config/nacos/cn/event-bridge-service.yml @@ -8,3 +8,6 @@ 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} diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index dd326ed80..910720455 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -7,6 +7,28 @@ spring: 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} + sml-automated: ${SML_TRACKING_ENABLED:false} + zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} + cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} + emc-automated: ${EMC_TRACKING_ENABLED:false} + hede-automated: ${HEDE_TRACKING_ENABLED:false} + scheduler: + zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} + scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} + scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} + batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} + 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: inbound: allow-rejected-measurement: false diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index 32dcc05c7..f735164e7 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -5,6 +5,11 @@ 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} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} diff --git a/config/nacos/cn/integration-service.yml b/config/nacos/cn/integration-service.yml index 5c3f3ed6c..d4dd01f9c 100644 --- a/config/nacos/cn/integration-service.yml +++ b/config/nacos/cn/integration-service.yml @@ -21,3 +21,39 @@ baiyue: max-qps-retries: ${BAIDU_OCR_MAX_QPS_RETRIES:3} 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/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 1676cb3a7..1123e5927 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -6,6 +6,18 @@ spring.datasource: 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.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: inbound: allow-rejected-measurement: false diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml index 4639f7ca1..cc1ad14ab 100644 --- a/config/nacos/local/iam-service.yml +++ b/config/nacos/local/iam-service.yml @@ -4,6 +4,9 @@ spring.datasource: username: ${IAM_DB_USERNAME:root} password: ${IAM_DB_PASSWORD:123456} baiyue: + 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} diff --git a/config/nacos/local/integration-service.yml b/config/nacos/local/integration-service.yml index 9ed48b24c..b02d9d323 100644 --- a/config/nacos/local/integration-service.yml +++ b/config/nacos/local/integration-service.yml @@ -15,3 +15,39 @@ baiyue: 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/overseas/README.md b/config/nacos/overseas/README.md new file mode 100644 index 000000000..95d89c508 --- /dev/null +++ b/config/nacos/overseas/README.md @@ -0,0 +1,5 @@ +# 海外 Nacos 配置 + +将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。 + +海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。 diff --git a/config/nacos/overseas/audit-service.yml b/config/nacos/overseas/audit-service.yml new file mode 100644 index 000000000..1ac49f251 --- /dev/null +++ b/config/nacos/overseas/audit-service.yml @@ -0,0 +1,6 @@ +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 new file mode 100644 index 000000000..a9e0a75f3 --- /dev/null +++ b/config/nacos/overseas/baiyue-common.yml @@ -0,0 +1,66 @@ +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: + 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 new file mode 100644 index 000000000..00a8ac755 --- /dev/null +++ b/config/nacos/overseas/event-bridge-service.yml @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..971474618 --- /dev/null +++ b/config/nacos/overseas/file-service.yml @@ -0,0 +1,13 @@ +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 new file mode 100644 index 000000000..a9c1eadf2 --- /dev/null +++ b/config/nacos/overseas/gateway-overseas.yml @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..25617fae1 --- /dev/null +++ b/config/nacos/overseas/integration-service.yml @@ -0,0 +1,12 @@ +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/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml new file mode 100644 index 000000000..c4d919be3 --- /dev/null +++ b/config/nacos/overseas/lastleg-service.yml @@ -0,0 +1,42 @@ +spring: + datasource: + url: ${LASTLEG_DB_URL} + username: ${LASTLEG_DB_USERNAME} + password: ${LASTLEG_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +lastleg: + dispatch-query-api: + enabled: true + smart-dispatch-api: + enabled: true +baiyue: + http: + clients: + unload-photo-file: + connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s} + relabel-photo-file: + connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} + warehouse-resource: + connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} + pallet-physical: + connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + 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} diff --git a/config/nacos/overseas/print-service.yml b/config/nacos/overseas/print-service.yml new file mode 100644 index 000000000..613433e3f --- /dev/null +++ b/config/nacos/overseas/print-service.yml @@ -0,0 +1,6 @@ +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/overseas/warehouse-overseas-service.yml b/config/nacos/overseas/warehouse-overseas-service.yml new file mode 100644 index 000000000..53f24467e --- /dev/null +++ b/config/nacos/overseas/warehouse-overseas-service.yml @@ -0,0 +1,11 @@ +spring: + datasource: + url: ${WAREHOUSE_DB_URL} + username: ${WAREHOUSE_DB_USERNAME} + password: ${WAREHOUSE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} diff --git a/deploy/.env.overseas-auth.example b/deploy/.env.overseas-auth.example new file mode 100644 index 000000000..325a37c3d --- /dev/null +++ b/deploy/.env.overseas-auth.example @@ -0,0 +1,12 @@ +# 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/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml new file mode 100644 index 000000000..24729ca6a --- /dev/null +++ b/deploy/docker-compose.overseas.yml @@ -0,0 +1,109 @@ +name: firstleg-${DEPLOY_ENV:-test}-overseas + +x-overseas-common: &overseas-common + restart: unless-stopped + networks: [firstleg-overseas-network, infra-network] + extra_hosts: + - "host.docker.internal:host-gateway" + environment: &overseas-environment + SPRING_PROFILES_ACTIVE: overseas + NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} + NACOS_USERNAME: ${NACOS_USERNAME} + NACOS_PASSWORD: ${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 + LOG_PATH: /app/logs + TZ: Asia/Shanghai + logging: + driver: json-file + options: + max-size: 100m + max-file: "5" + +services: + gateway-overseas: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"] + 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 + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"] + 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} + 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 + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"] + 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} + + event-bridge-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"] + 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.yml b/deploy/docker-compose.yml index aac92b985..8e61b9ec7 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -39,7 +39,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-gateway - ports: ["${GATEWAY_PORT:-9000}:9000"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"] environment: <<: *common-environment @@ -52,7 +52,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-auth - ports: ["${AUTH_PORT:-9100}:9100"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"] environment: <<: *common-environment @@ -60,12 +60,18 @@ services: 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 - ports: ["${IAM_PORT:-9201}:9201"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"] environment: <<: *common-environment @@ -73,12 +79,13 @@ services: 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 - ports: ["${FIRSTLEG_PORT:-9202}:9202"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"] environment: <<: *common-environment @@ -86,12 +93,25 @@ services: 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} + COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false} + EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false} + HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false} + CARRIER_SCHEDULE_ZONE: ${CARRIER_SCHEDULE_ZONE:-Asia/Shanghai} + CARRIER_SCHEDULE_SCAN_FIXED_DELAY: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:-PT1M} + CARRIER_SCHEDULE_SCAN_INITIAL_DELAY: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:-PT10S} + CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20} + CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000} + CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M} finance: <<: *common image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-finance - ports: ["${FINANCE_PORT:-9203}:9203"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"] environment: <<: *common-environment @@ -104,7 +124,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-order - ports: ["${ORDER_PORT:-9204}:9204"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"] environment: <<: *common-environment @@ -117,7 +137,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-warehouse - ports: ["${WAREHOUSE_PORT:-9205}:9205"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"] environment: <<: *common-environment @@ -130,7 +150,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-file - ports: ["${FILE_PORT:-9206}:9206"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"] volumes: - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" - "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs" @@ -154,7 +174,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-print - ports: ["${PRINT_PORT:-9207}:9207"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"] environment: <<: *common-environment @@ -165,7 +185,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-integration - ports: ["${INTEGRATION_PORT:-9210}:9210"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"] environment: <<: *common-environment @@ -180,19 +200,56 @@ services: AMAZON_SP_API_AWS_ACCESS_KEY: ${AMAZON_SP_API_AWS_ACCESS_KEY:-} AMAZON_SP_API_AWS_SECRET_KEY: ${AMAZON_SP_API_AWS_SECRET_KEY:-} AMAZON_SP_API_REGION: ${AMAZON_SP_API_REGION:-} + MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false} + MATSON_TRACKING_PAGE_URL: ${MATSON_TRACKING_PAGE_URL:-https://www.matson.com/shipment-tracking.html} + MATSON_TRACKING_QUERY_URL: ${MATSON_TRACKING_QUERY_URL:-https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} + MATSON_TRACKING_CONNECT_TIMEOUT: ${MATSON_TRACKING_CONNECT_TIMEOUT:-10s} + MATSON_TRACKING_REQUEST_TIMEOUT: ${MATSON_TRACKING_REQUEST_TIMEOUT:-30s} + MATSON_TRACKING_MIN_REQUEST_INTERVAL: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:-5s} + SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false} + SML_TRACKING_QUERY_URL: ${SML_TRACKING_QUERY_URL:-https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} + SML_TRACKING_CONNECT_TIMEOUT: ${SML_TRACKING_CONNECT_TIMEOUT:-10s} + SML_TRACKING_REQUEST_TIMEOUT: ${SML_TRACKING_REQUEST_TIMEOUT:-30s} + SML_TRACKING_MIN_REQUEST_INTERVAL: ${SML_TRACKING_MIN_REQUEST_INTERVAL:-5s} + ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false} + ZIM_AUTHORIZATION_URL: ${ZIM_AUTHORIZATION_URL:-https://apigw.zim.com/authorize/v1} + ZIM_TRACING_BASE_URL: ${ZIM_TRACING_BASE_URL:-https://apigw.zim.com/tracing/v2} + ZIM_CLIENT_ID: ${ZIM_CLIENT_ID:-} + ZIM_CLIENT_SECRET: ${ZIM_CLIENT_SECRET:-} + ZIM_TRACKING_SUBSCRIPTION_KEY: ${ZIM_TRACKING_SUBSCRIPTION_KEY:-} + ZIM_TRACKING_CONNECT_TIMEOUT: ${ZIM_TRACKING_CONNECT_TIMEOUT:-10s} + ZIM_TRACKING_REQUEST_TIMEOUT: ${ZIM_TRACKING_REQUEST_TIMEOUT:-30s} + ZIM_TRACKING_MAX_CONCURRENCY: ${ZIM_TRACKING_MAX_CONCURRENCY:-10} + EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false} + EMC_TRACKING_QUERY_URL: ${EMC_TRACKING_QUERY_URL:-https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} + EMC_TRACKING_CONNECT_TIMEOUT: ${EMC_TRACKING_CONNECT_TIMEOUT:-10s} + EMC_TRACKING_REQUEST_TIMEOUT: ${EMC_TRACKING_REQUEST_TIMEOUT:-30s} + EMC_TRACKING_MIN_REQUEST_INTERVAL: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:-5s} + HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false} + HEDE_TRACKING_QUERY_URL: ${HEDE_TRACKING_QUERY_URL:-http://elines.hedehk.com/getVBilldynamic} + HEDE_TRACKING_CONNECT_TIMEOUT: ${HEDE_TRACKING_CONNECT_TIMEOUT:-10s} + 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 - ports: ["${EVENT_BRIDGE_PORT:-9300}:9300"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"] environment: <<: *common-environment - SERVER_PORT: 9300 + 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} + SPRING_SQL_INIT_MODE: never + networks: + firstleg-network: + infra-network: + aliases: [event-bridge-cn] audit-service: <<: *common diff --git a/deploy/maven/settings-cn.xml b/deploy/maven/settings-cn.xml index 96d2d9207..d3516a762 100644 --- a/deploy/maven/settings-cn.xml +++ b/deploy/maven/settings-cn.xml @@ -4,36 +4,54 @@ xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd"> - aliyun-public - 阿里云 Maven 公共仓库 - https://maven.aliyun.com/repository/public - central + baiyue-nexus-public + 柏悦 Nexus Maven 聚合仓库 + http://8.163.30.128:18081/repository/maven-public/ + external:* + + + baiyue-nexus-public + ${env.NEXUS_USERNAME} + ${env.NEXUS_PASSWORD} + + + baiyue-nexus-releases + ${env.NEXUS_USERNAME} + ${env.NEXUS_PASSWORD} + + + baiyue-nexus-snapshots + ${env.NEXUS_USERNAME} + ${env.NEXUS_PASSWORD} + + + - 国内仓库 + 柏悦 Nexus - central - https://repo.maven.apache.org/maven2 + baiyue-nexus-public + http://8.163.30.128:18081/repository/maven-public/ true - false + true - central - https://repo.maven.apache.org/maven2 + baiyue-nexus-public + http://8.163.30.128:18081/repository/maven-public/ true - false + true - 国内仓库 + 柏悦 Nexus diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index f8520ae03..d4ad1ad7d 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -15,6 +15,18 @@ COMPOSE_FILE="deploy/docker-compose.yml" IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG" REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" PROJECT_NAME="firstleg-$DEPLOY_ENV" +HEALTH_TIMEOUT_SECONDS="${DEPLOY_HEALTH_TIMEOUT_SECONDS:-240}" +HEALTH_POLL_SECONDS="${DEPLOY_HEALTH_POLL_SECONDS:-5}" + +for value_name in HEALTH_TIMEOUT_SECONDS HEALTH_POLL_SECONDS; do + eval "value=\${$value_name}" + case "$value" in + ''|*[!0-9]*|0) + echo "$value_name 必须为正整数,当前: $value" >&2 + exit 2 + ;; + esac +done case "$DEPLOY_ENV" in development|test) ;; @@ -24,6 +36,13 @@ case "$DEPLOY_ENV" in ;; 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 "$SERVICE" in *[!A-Za-z0-9_-]*|'') echo "非法服务名: $SERVICE" >&2 @@ -31,12 +50,10 @@ case "$SERVICE" in ;; esac -for file in "$ENV_FILE" "$COMPOSE_FILE"; do - if [ ! -f "$file" ]; then - echo "必需文件不存在: $file" >&2 - exit 2 - fi -done +if [ ! -f "$ENV_FILE" ]; then + echo "必需文件不存在: $ENV_FILE" >&2 + exit 2 +fi env_file_has_key() { grep -q "^$1=" "$ENV_FILE" @@ -46,6 +63,48 @@ 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 +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 @@ -55,6 +114,56 @@ if [ "$SERVICE" = "finance" ]; then done fi +case "$SERVICE" in + gateway-overseas|lastleg-service|warehouse-overseas-service) + for key in NACOS_SERVER_ADDR 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 + ;; +esac + +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 +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; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + 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 @@ -82,23 +191,149 @@ local_registry_logout() { docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true } +local_compose() { + docker compose \ + --project-name "$PROJECT_NAME" \ + --env-file "$ENV_FILE" \ + -f "$COMPOSE_FILE" \ + "$@" +} + +local_container_id() { + local_compose ps --all --quiet "$SERVICE" | head -n 1 +} + +local_container_image() { + container_id="$(local_container_id)" + if [ -n "$container_id" ]; then + docker inspect --format '{{.Config.Image}}' "$container_id" + fi +} + +local_health_state() { + container_id="$(local_container_id)" + if [ -z "$container_id" ]; then + echo missing + return + fi + docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health:{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || echo missing +} + +local_runtime_state() { + container_id="$(local_container_id)" + if [ -z "$container_id" ]; then + echo missing + return + fi + docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || echo missing +} + +wait_for_local_health() { + elapsed=0 + while [ "$elapsed" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do + runtime_state="$(local_runtime_state)" + health_state="$(local_health_state)" + echo "容器运行状态: $runtime_state,健康状态: $health_state(已等待 ${elapsed}s)" + case "$runtime_state" in + restarting|exited|dead) + echo "容器未保持运行,立即停止等待: $runtime_state" >&2 + return 1 + ;; + esac + case "$health_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 +} + deploy_local() { 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)" + if [ -n "$previous_image" ]; then + echo "部署前运行镜像: $previous_image" + else + echo "首次部署,未发现可回滚的旧镜像" + fi docker pull "$IMAGE" export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - up -d --remove-orphans --pull never "$SERVICE" - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - ps "$SERVICE" - local_registry_logout - trap - EXIT HUP INT TERM + new_started=true + if ! local_compose up -d --no-deps --pull never "$SERVICE"; then + echo "新镜像启动命令失败: $IMAGE" >&2 + new_started=false + fi + if [ "$new_started" = "true" ] && wait_for_local_health; then + running_image="$(local_container_image || true)" + if [ "$running_image" != "$IMAGE" ]; then + echo "运行容器镜像与请求镜像不一致: expected=$IMAGE actual=${running_image:-missing}" >&2 + return 1 + fi + echo "新镜像健康检查通过: $IMAGE" + local_compose ps "$SERVICE" + local_registry_logout + trap - EXIT HUP INT TERM + return 0 + fi + + echo "新镜像健康检查失败: $IMAGE" >&2 + failed_container_id="$(local_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 + local_compose stop "$SERVICE" >/dev/null 2>&1 || true + return 1 + ;; + *) + echo "旧镜像不属于当前服务,拒绝自动回滚: $previous_image" >&2 + return 1 + ;; + esac + if [ "$previous_image" = "$IMAGE" ]; then + echo "旧镜像与新镜像相同,无法自动回滚: $IMAGE" >&2 + return 1 + fi + + echo "开始自动回滚到: $previous_image" >&2 + if ! docker pull "$previous_image"; then + echo "自动回滚失败,无法拉取旧镜像: $previous_image" >&2 + return 1 + fi + IMAGE_TAG="$previous_tag" + export IMAGE_TAG + if ! local_compose up -d --no-deps --pull never "$SERVICE"; then + echo "自动回滚失败,旧镜像启动命令失败: $previous_image" >&2 + return 1 + fi + if wait_for_local_health; then + echo "自动回滚成功: $previous_image" >&2 + local_compose ps "$SERVICE" || true + else + echo "自动回滚失败,旧镜像未恢复健康: $previous_image" >&2 + fi + return 1 } if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then @@ -142,19 +377,22 @@ run_scp() { run_ssh install -d -m 750 "$REMOTE_DIR" SUFFIX="${IMAGE_TAG}.$$.${SERVICE}" -REMOTE_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp" -REMOTE_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp" +REMOTE_ENV_NAME=".env.$PROJECT_NAME" +REMOTE_COMPOSE_NAME="docker-compose.$PROJECT_NAME.yml" +REMOTE_ENV_TMP="$REMOTE_DIR/$REMOTE_ENV_NAME.$SUFFIX.tmp" +REMOTE_COMPOSE_TMP="$REMOTE_DIR/$REMOTE_COMPOSE_NAME.$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' +run_ssh sh -s -- "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" <<'REMOTE_PREPARE' set -eu -remote_dir="$1" -env_tmp="$2" -compose_tmp="$3" +env_tmp="$1" +compose_tmp="$2" +env_target="$3" +compose_target="$4" chmod 600 "$env_tmp" chmod 644 "$compose_tmp" -mv -f "$env_tmp" "$remote_dir/.env" -mv -f "$compose_tmp" "$remote_dir/docker-compose.yml" +mv -f "$env_tmp" "$env_target" +mv -f "$compose_tmp" "$compose_target" REMOTE_PREPARE remote_registry_logout() { @@ -166,27 +404,144 @@ printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --pa trap remote_registry_logout EXIT HUP INT TERM run_ssh docker pull "$IMAGE" -run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" <<'REMOTE_DEPLOY' +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" -docker compose \ - --project-name "firstleg-$deploy_env" \ - --env-file .env \ - -f docker-compose.yml \ - up -d --remove-orphans --pull never "$service" -docker compose \ - --project-name "firstleg-$deploy_env" \ - --env-file .env \ - -f docker-compose.yml \ - ps "$service" +if [ "$deploy_env" = "development" ]; then + export PUBLISHED_BIND_ADDRESS=127.0.0.1 +else + export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" +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="$(remote_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 diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh index 459337f8a..23343f7a4 100644 --- a/deploy/scripts/deploy-development-service.sh +++ b/deploy/scripts/deploy-development-service.sh @@ -15,7 +15,8 @@ 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/docker-compose.yml" +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 @@ -63,21 +64,22 @@ if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then cd "$REMOTE_DIR" export DEPLOY_ENV=development export IMAGE_REPOSITORY IMAGE_TAG + export PUBLISHED_BIND_ADDRESS=127.0.0.1 docker compose \ - --project-name firstleg-development \ + --project-name "$PROJECT_NAME" \ --env-file .env \ -f docker-compose.yml \ pull $SERVICES docker compose \ - --project-name firstleg-development \ + --project-name "$PROJECT_NAME" \ --env-file .env \ -f docker-compose.yml \ up -d --remove-orphans --pull never $SERVICES docker compose \ - --project-name firstleg-development \ + --project-name "$PROJECT_NAME" \ --env-file .env \ -f docker-compose.yml \ ps $SERVICES @@ -129,27 +131,29 @@ for service in $SERVICES; do docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load' done -run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" <<'REMOTE_DEPLOY' +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 firstleg-development \ + --project-name "$project_name" \ --env-file .env \ -f docker-compose.yml \ up -d --remove-orphans --pull never $services docker compose \ - --project-name firstleg-development \ + --project-name "$project_name" \ --env-file .env \ -f docker-compose.yml \ ps $services diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index 41a2c0b9b..7c9983f82 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -19,6 +19,15 @@ 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 + env_file_has_key() { grep -q "^$1=" "$ENV_FILE" } @@ -27,6 +36,59 @@ 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_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 @@ -38,6 +100,53 @@ case " $SERVICES " in ;; 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; 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 @@ -75,24 +184,24 @@ esac export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG if [ "$DEPLOY_ENV" = "development" ]; then - exec sh deploy/scripts/deploy-development-service.sh \ + 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 docker compose \ - --project-name "firstleg-${DEPLOY_ENV}" \ + --project-name "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ - -f deploy/docker-compose.yml \ + -f "$COMPOSE_FILE" \ pull $SERVICES docker compose \ - --project-name "firstleg-${DEPLOY_ENV}" \ + --project-name "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ - -f deploy/docker-compose.yml \ + -f "$COMPOSE_FILE" \ up -d --remove-orphans --pull never $SERVICES docker compose \ - --project-name "firstleg-${DEPLOY_ENV}" \ + --project-name "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ - -f deploy/docker-compose.yml \ + -f "$COMPOSE_FILE" \ ps $SERVICES diff --git a/deploy/service-map.yml b/deploy/service-map.yml index 7b8008371..e22eec804 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -3,6 +3,11 @@ 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 @@ -35,10 +40,21 @@ services: artifactId: integration-service jar: integration-service/target/integration-service-1.0.0-SNAPSHOT.jar port: 9210 + lastleg-service: + 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: 9300 + 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 0f4dc2457..5a946294c 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -2,15 +2,19 @@ ## 唯一数据库初始化与发布入口 -- 固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 +- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口会创建并校验独立的 `baiyue_event_bridge_overseas`,再升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 +- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。 +- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口;义乌、东莞、洛杉矶、纽约四个固定作业仓均初始化 `RECEIVING`,其中洛杉矶和纽约使用海外接柜入仓收货语义,FBA 目的地址不创建作业能力。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 +- IAM 跨区安全事件基线已内联到固定入口,包括主体权限版本、客户状态版本和可靠事件发送箱 3 张表;模块溯源脚本为 `iam/src/main/resources/db/security-event-outbox.sql`。IAM 新版本启动前必须先执行固定入口,避免安全事件 Relay 和授权快照查询因缺表失败。 - 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。 后续追加增量必须满足: @@ -55,6 +59,8 @@ 在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。 +执行前先重新连接目标数据库,开启客户端 keepalive,并关闭“遇错继续执行”;出现连接断开、语法错误或 `SQLSTATE 45000` 后不得从失败位置续跑。由于 DDL 会隐式提交,存量环境可能已经部分更新;修复失败原因后应使用同一固定入口从第一行完整重跑,依靠脚本的元数据判断和幂等写入收敛结构。若出现 MySQL `4031`,先处理客户端连接保活或由数据库管理员调整 `wait_timeout` / `interactive_timeout`,再重新连接执行。 + 执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。 ## 三期分柜、预装、拣货与装柜当前基线 @@ -214,3 +220,9 @@ SELECT - Warehouse 新增 `wh_overseas_storage_fee_rule` 和 `wh_overseas_storage_fee_tier`,包含客户唯一索引、分页索引、阶梯起始天唯一索引和规则明细查询索引。 - IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。 - Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。 + +## 四期海外权限目录 + +- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。 +- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。 +- 本批不创建箱号、产品库、海外出库、退货出库或海外派送权限。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 8220b788a..6f43de038 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -129,7 +129,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', @@ -160,7 +160,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', @@ -184,6 +184,41 @@ CREATE TABLE IF NOT EXISTS bridge_inbox ( KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until) ) ENGINE=InnoDB COMMENT='跨区域事件已接收箱'; +-- 旧开发库曾使用 tenant_id;当前 Event Bridge 实体统一为 customer_id。幂等改名并保留原数据。 +SET @bridge_inbox_customer_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_inbox' AND column_name = 'customer_id' +); +SET @bridge_inbox_tenant_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_inbox' AND column_name = 'tenant_id' +); +SET @bridge_inbox_column_sql = IF( + @bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1, + 'ALTER TABLE bridge_inbox RENAME COLUMN tenant_id TO customer_id', + 'SELECT 1' +); +PREPARE bridge_inbox_column_statement FROM @bridge_inbox_column_sql; +EXECUTE bridge_inbox_column_statement; +DEALLOCATE PREPARE bridge_inbox_column_statement; + +SET @bridge_outbox_customer_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_outbox' AND column_name = 'customer_id' +); +SET @bridge_outbox_tenant_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_outbox' AND column_name = 'tenant_id' +); +SET @bridge_outbox_column_sql = IF( + @bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1, + 'ALTER TABLE bridge_outbox RENAME COLUMN tenant_id TO customer_id', + 'SELECT 1' +); +PREPARE bridge_outbox_column_statement FROM @bridge_outbox_column_sql; +EXECUTE bridge_outbox_column_statement; +DEALLOCATE PREPARE bridge_outbox_column_statement; + CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', @@ -243,19 +278,16 @@ PREPARE auth_password_change_required_stmt FROM @auth_password_change_required_s EXECUTE auth_password_change_required_stmt; DEALLOCATE PREPARE auth_password_change_required_stmt; --- 开发/测试环境最小登录基线:一个平台系统管理员和一个 IT 测试客户管理员。 --- 初始密码统一为 ChangeMe123!;只保存 BCrypt 哈希,不写入明文。 +-- 开发/测试环境最小登录基线只保留平台系统管理员;客户必须走正式建档/注册流程创建。 +-- 缺失账号首次创建时写入开发基线 BCrypt 哈希;重复执行统一增量绝不覆盖现有密码。 INSERT INTO auth_account ( id, username, password_hash, display_name, account_status, failed_login_count, token_version, password_change_required, default_tenant_id, operator_tenant_id, default_membership_id, default_app_code, deleted ) VALUES (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0), - (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) + '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0) ON DUPLICATE KEY UPDATE - password_hash = VALUES(password_hash), display_name = VALUES(display_name), account_status = 'ACTIVE', failed_login_count = 0, @@ -404,6 +436,7 @@ CREATE TABLE IF NOT EXISTS iam_membership ( user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', + overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', joined_at DATETIME(3) NULL COMMENT '加入客户企业时间', left_at DATETIME(3) NULL COMMENT '离开客户企业时间', @@ -418,6 +451,13 @@ CREATE TABLE IF NOT EXISTS iam_membership ( KEY idx_membership_tenant (tenant_id, status) ) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='iam_membership' AND column_name='overseas_warehouse_id'), + 'SELECT 1', 'ALTER TABLE iam_membership ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外仓PDA操作员绑定的唯一实际海外仓ID'' AFTER member_no')); +PREPARE iam_membership_overseas_warehouse_stmt FROM @ddl; +EXECUTE iam_membership_overseas_warehouse_stmt; +DEALLOCATE PREPARE iam_membership_overseas_warehouse_stmt; + CREATE TABLE IF NOT EXISTS iam_role ( id BIGINT NOT NULL COMMENT '角色ID', tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色作用域:0为平台预置模板,正数为客户ID和数据隔离键', @@ -541,6 +581,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( container_mode VARCHAR(24) NULL COMMENT '装运方式:FULL_CONTAINER整柜、LCL散货拼箱', customs_inspection TINYINT(1) NULL COMMENT '是否需要商检:0否、1是', destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', amazon_warehouse_code VARCHAR(32) NULL COMMENT 'Amazon目的仓编码', shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID', reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID', @@ -562,6 +603,26 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +SET @order_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE baiyue_order.ord_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER destination_type' +); +PREPARE order_fulfillment_type_stmt FROM @order_fulfillment_type_sql; +EXECUTE order_fulfillment_type_stmt; +DEALLOCATE PREPARE order_fulfillment_type_stmt; + +UPDATE baiyue_order.ord_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE baiyue_order.ord_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + -- 既有开发库可能仍保留草稿入仓码 NOT NULL 约束;必须先放宽再清理草稿入仓码。 SET @order_inbound_code_nullable_sql = IF( EXISTS( @@ -751,7 +812,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库固定使用 tenant_id=0', warehouse_code VARCHAR(50) NOT NULL COMMENT '平台内唯一仓库编码', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', - warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC、FBA、OVERSEAS', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地UTC偏移量', contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', @@ -829,6 +890,7 @@ CREATE TABLE IF NOT EXISTS wh_partner ( data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围', partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称', partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CARRIER、SHIPPER或CONSIGNEE', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填', contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码', province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县', @@ -840,7 +902,8 @@ CREATE TABLE IF NOT EXISTS wh_partner ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted), KEY idx_wh_partner_tenant_status(tenant_id,status,deleted), - CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')) + CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')), + CONSTRAINT chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')) ) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据'; CREATE TABLE IF NOT EXISTS wh_partner_type ( partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0', @@ -1131,10 +1194,7 @@ INSERT INTO iam_tenant ( approved_at, approved_by, created_by, deleted, version ) VALUES (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', - NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), - (10001, 'IT_TEST', 'IT 测试客户', 'MERCHANT', 'ACTIVE', 'IT 测试客户', - '91310000ITTEST0001', 'IT 测试客户管理员', '13800000000', 'it-test@example.com', - NOW(3), 1000001, 1000001, 0, 0) + NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), @@ -1143,28 +1203,10 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; --- The built-in IT customer must be immediately usable for order acceptance tests. --- These approved metadata rows intentionally have no document URL: initialization must not --- point at qualification files that do not exist in File service storage. -INSERT INTO iam_tenant_qualification ( - id, tenant_id, qualification_type, certificate_no, document_url, - valid_from, valid_to, status, review_comment, deleted -) VALUES - (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), - (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) -ON DUPLICATE KEY UPDATE - tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), - certificate_no = VALUES(certificate_no), status = 'APPROVED', - review_comment = VALUES(review_comment), deleted = 0; - INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES - (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0), - (2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员', '13800000000', - 'it-test@example.com', 'ACTIVE', 0, 0) + (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), status = 'ACTIVE', deleted = 0; @@ -1172,8 +1214,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_membership ( id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version ) VALUES - (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), - (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0) ON DUPLICATE KEY UPDATE member_type = VALUES(member_type), member_no = VALUES(member_no), status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; @@ -1183,9 +1224,7 @@ INSERT INTO iam_role ( ) VALUES (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), - (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), - (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), - (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -1272,39 +1311,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE id IN (5102019, 5102024, 5102025) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- Merchant: level-one menus, level-two menus and buttons. -INSERT INTO iam_permission - (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) -VALUES - (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), - (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), - (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), - (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), - (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), - (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), - (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), - (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), - (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), - (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), - (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), - (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), - (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), - (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), - (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), - (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), - (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), - (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), - (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), - (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), - (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES - (3000001, 4000001, 0), - (3000002, 4000101, 10001) + (3000001, 4000001, 0) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- Full administrator roles receive all phase-one permissions in their application scope. @@ -1313,11 +1321,6 @@ SELECT 4000001, id, 0 FROM iam_permission WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - -- Limited platform and merchant operational roles. INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000002, id, 0 FROM iam_permission @@ -1333,17 +1336,10 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000102, id, 10001 FROM iam_permission -WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES (6000001, 4000001, 0, 'TENANT', NULL), (6000002, 4000002, 0, 'TENANT', NULL), - (6000003, 4000003, 0, 'TENANT', NULL), - (6000101, 4000101, 10001, 'TENANT', NULL), - (6000102, 4000102, 10001, 'TENANT', NULL) + (6000003, 4000003, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; -- ============================================================================ @@ -1392,17 +1388,34 @@ INSERT INTO iam_role ( ) ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; --- Clone the一期 merchant menu/button template into the global scope. +-- Global merchant permission template used by every formally onboarded customer. INSERT INTO iam_permission ( id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted ) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') +VALUES + (5300000, 0, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5301000, 0, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5302000, 0, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5300010, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5300020, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5300030, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5301010, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5301020, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5302010, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5302020, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5300011, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5300012, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5300021, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5300022, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:grant-role', '分配成员角色', NULL, NULL, 20, 'ACTIVE', 0), + (5300031, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5300032, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5301011, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5301012, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5301021, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5301022, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5301023, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; @@ -1444,20 +1457,11 @@ DEALLOCATE PREPARE member_no_index_stmt; INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) VALUES - (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), - (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), - (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND permission_code IN - ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND permission_code IN @@ -1485,9 +1489,6 @@ VALUES (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), - (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), - (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), - (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) @@ -1506,12 +1507,6 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'MERCHANT' @@ -1879,7 +1874,8 @@ CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code'); CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name'); -CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER carrier_code'); CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name'); CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL'); CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email'); @@ -1895,6 +1891,8 @@ CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL'); CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0'); +UPDATE wh_partner SET carrier_code = 'OTHER' + WHERE id = 191000000000000001 AND partner_type = 'CARRIER' AND carrier_code IS NULL; -- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。 CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); @@ -2489,6 +2487,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', channel_code VARCHAR(64) NULL COMMENT '渠道编码', channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', @@ -2539,6 +2538,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +SET @inbound_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type' +); +PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql; +EXECUTE inbound_fulfillment_type_stmt; +DEALLOCATE PREPARE inbound_fulfillment_type_stmt; + +UPDATE fl_inbound_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE fl_inbound_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + SET @inbound_global_code_index_exists := ( SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' @@ -4202,23 +4221,9 @@ SET @unified_ddl = IF( PREPARE unified_stmt FROM @unified_ddl; EXECUTE unified_stmt; DEALLOCATE PREPARE unified_stmt; -- ============================================================================ --- 重新同步一期商户管理员全局模板,包含一期后续追加的菜单和按钮 +-- 重新授权一期商户管理员全局模板,包含一期后续追加的菜单和按钮 -- ============================================================================ USE baiyue_iam; -INSERT INTO iam_permission ( - id, tenant_id, app_code, permission_type, parent_id, permission_code, - permission_name, route_path, http_method, sort_no, status, deleted -) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - parent_id = VALUES(parent_id), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT owner_role.id, permission.id, 0 FROM iam_role owner_role @@ -4281,8 +4286,10 @@ CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id'); CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name'); CALL unified_assert_column('baiyue_order', 'ord_order', 'pricing_tax_mode'); CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code'); +CALL unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode'); +CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_channel_catalog'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_origin_alias'); @@ -4938,6 +4945,21 @@ BEGIN ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='customer_id') THEN + ALTER TABLE fl_container_booking ADD COLUMN customer_id BIGINT UNSIGNED NULL + COMMENT '已关联真实订舱指定的客户范围' AFTER ocean_booking_requirement_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='customer_name') THEN + ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL + COMMENT '已关联真实订舱客户名称快照' AFTER customer_id; + END IF; + UPDATE fl_container_booking container_booking + JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id + SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id), + container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name) + WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL; IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no); @@ -5587,14 +5609,23 @@ BEGIN SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN IF EXISTS (SELECT 1 FROM fl_loading_plan_item item - LEFT JOIN fl_loading_pick_item pick_item - ON pick_item.loading_plan_item_id = item.id - LEFT JOIN fl_loading_pick_task pick_task - ON pick_task.id = pick_item.pick_task_id - AND pick_task.loading_plan_id = OLD.id WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 - AND (pick_task.id IS NULL - OR pick_item.picked_package_count <> item.package_count)) THEN + AND NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id + WHERE pick_item.loading_plan_item_id = item.id + AND pick_task.loading_plan_id = OLD.id + AND pick_task.task_status = 'LOADING' + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) + )) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; END IF; SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); @@ -5873,7 +5904,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -5889,7 +5920,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ @@ -6330,7 +6361,7 @@ JOIN wh_warehouse warehouse ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- 国内固定作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 +-- 四个固定平台作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 INSERT INTO wh_service_capability ( id, tenant_id, warehouse_id, capability_code, capability_name, enabled, cutoff_time, description, created_by, created_at, updated_by, updated_at, deleted, version @@ -6339,7 +6370,11 @@ VALUES (193000000000400001,0,193000000000200001,'RECEIVING','头程收货',1,'16:00:00', '义乌仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), (193000000000400002,0,193000000000200002,'RECEIVING','头程收货',1,'16:00:00', - '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) + '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00', + '洛杉矶仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00', + '纽约仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) ON DUPLICATE KEY UPDATE capability_name = VALUES(capability_name), enabled = VALUES(enabled), @@ -6986,11 +7021,11 @@ BEGIN IF (SELECT COUNT(*) FROM wh_service_capability capability WHERE capability.tenant_id = 0 - AND capability.warehouse_id IN (193000000000200001,193000000000200002) + AND capability.warehouse_id IN (193000000000200001,193000000000200002,193000000000200003,193000000000200004) AND capability.capability_code = 'RECEIVING' AND capability.enabled = 1 - AND capability.deleted = 0) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内固定作业仓收货能力写入校验失败'; + AND capability.deleted = 0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台作业仓收货能力写入校验失败'; END IF; END// DELIMITER ; @@ -7477,6 +7512,9 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增', items_json JSON NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据', remark VARCHAR(1000) NULL COMMENT '预装备注', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID', + confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照', + confirmed_at DATETIME(3) NULL COMMENT '预装确认时间', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建预装清单的操作人ID', created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', @@ -7489,6 +7527,30 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan ( CONSTRAINT fk_preload_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='订单分柜预装清单'; +DROP PROCEDURE IF EXISTS migrate_preload_confirmation_columns; +DELIMITER $$ +CREATE PROCEDURE migrate_preload_confirmation_columns() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_by') THEN + ALTER TABLE fl_preload_plan + ADD COLUMN confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID' AFTER remark; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_by_name') THEN + ALTER TABLE fl_preload_plan + ADD COLUMN confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照' AFTER confirmed_by; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_preload_plan' AND column_name='confirmed_at') THEN + ALTER TABLE fl_preload_plan + ADD COLUMN confirmed_at DATETIME(3) NULL COMMENT '预装确认时间' AFTER confirmed_by_name; + END IF; +END$$ +DELIMITER ; +CALL migrate_preload_confirmation_columns(); +DROP PROCEDURE IF EXISTS migrate_preload_confirmation_columns; + CREATE TABLE IF NOT EXISTS fl_split_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -8972,6 +9034,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填', booking_agent_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照', booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照', vessel_name VARCHAR(128) NULL COMMENT '当前确认船名快照', @@ -9000,10 +9063,38 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0), CONSTRAINT chk_ocean_booking_status CHECK (status IN ('DRAFT','RELEASED')), + CONSTRAINT chk_ocean_booking_carrier_code CHECK + (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_ocean_booking_schedule CHECK (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; +SET @ocean_add_carrier_code = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE fl_ocean_booking ADD COLUMN carrier_code VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填'' AFTER shipping_company_name', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' AND column_name='carrier_code' +); +PREPARE ocean_add_carrier_code_stmt FROM @ocean_add_carrier_code; +EXECUTE ocean_add_carrier_code_stmt; +DEALLOCATE PREPARE ocean_add_carrier_code_stmt; + +SET @ocean_drop_carrier_code_check = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE fl_ocean_booking DROP CHECK chk_ocean_booking_carrier_code', + 'SELECT 1') + FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_ocean_booking' + AND constraint_name='chk_ocean_booking_carrier_code' AND constraint_type='CHECK' +); +PREPARE ocean_drop_carrier_code_check_stmt FROM @ocean_drop_carrier_code_check; +EXECUTE ocean_drop_carrier_code_check_stmt; +DEALLOCATE PREPARE ocean_drop_carrier_code_check_stmt; +ALTER TABLE fl_ocean_booking + ADD CONSTRAINT chk_ocean_booking_carrier_code CHECK + (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')); + -- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional. ALTER TABLE fl_ocean_booking MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', @@ -9190,24 +9281,23 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_ocean_booking_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102140 AND 5102149 - AND permission_code <> CASE id - WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list' - WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list' - WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create' - WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release' - WHEN 5102147 THEN 'platform:firstleg:ocean-booking:edit' - WHEN 5102148 THEN 'platform:firstleg:ocean-booking:delete' - WHEN 5102149 THEN 'platform:firstleg:ocean-booking:allocate' - END) + WHERE (id = 5102140 AND permission_code <> 'platform:firstleg:ocean-booking:list') + OR (id = 5102142 AND permission_code <> 'platform:firstleg:ocean-booking:create') + OR (id = 5102146 AND permission_code <> 'platform:firstleg:ocean-booking:release') + OR (id = 5102147 AND permission_code <> 'platform:firstleg:ocean-booking:edit') + OR (id = 5102148 AND permission_code <> 'platform:firstleg:ocean-booking:delete') + OR (id = 5102149 AND permission_code <> 'platform:firstleg:ocean-booking:allocate') OR (permission_code LIKE 'platform:firstleg:ocean-booking:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' + OR id NOT IN (5102140,5102142,5102146,5102147,5102148,5102149))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用'; @@ -9236,9 +9326,6 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); -DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); -DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); - USE baiyue_firstleg; @@ -9321,14 +9408,13 @@ CREATE PROCEDURE assert_truck_appointment_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102150 AND 5102154 - AND permission_code <> CASE id - WHEN 5102150 THEN 'platform:firstleg:truck-appointment:list' - WHEN 5102151 THEN 'platform:firstleg:truck-appointment:list' - WHEN 5102152 THEN 'platform:firstleg:truck-appointment:create' - WHEN 5102153 THEN 'platform:firstleg:truck-appointment:edit' - WHEN 5102154 THEN 'platform:firstleg:truck-appointment:operate' - END) + WHERE (id = 5102150 AND permission_code <> 'platform:firstleg:truck-appointment:list') + OR (id = 5102151 AND permission_code NOT IN ( + 'platform:firstleg:truck-appointment:list', + CONCAT('platform:firstleg:truck-appointment:', 'view'))) + OR (id = 5102152 AND permission_code <> 'platform:firstleg:truck-appointment:create') + OR (id = 5102153 AND permission_code <> 'platform:firstleg:truck-appointment:edit') + OR (id = 5102154 AND permission_code <> 'platform:firstleg:truck-appointment:operate') OR (permission_code LIKE 'platform:firstleg:truck-appointment:%' AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102150 AND 5102154)) ) THEN @@ -9358,6 +9444,10 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission AND permission.permission_code LIKE 'platform:firstleg:truck-appointment:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); +DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id +WHERE p.id=5102151; +DELETE FROM iam_permission WHERE id=5102151; + USE baiyue_firstleg; DROP PROCEDURE IF EXISTS assert_phase3_service_center_schema; @@ -9374,10 +9464,12 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102140 AND 5102149 + WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 + AND id BETWEEN 5102140 AND 5102149 AND permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 6 OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102150 AND 5102154 + WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 + AND id BETWEEN 5102150 AND 5102154 AND permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约权限未完整写入'; END IF; @@ -9636,21 +9728,28 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:port-schedule:view'; +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule:view'; + DROP PROCEDURE IF EXISTS assert_port_schedule_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_port_schedule_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102180 AND 5102183 + WHERE (id IN (5102180,5102182,5102183) AND permission_code <> CASE id WHEN 5102180 THEN 'platform:firstleg:port-schedule:list' - WHEN 5102181 THEN 'platform:firstleg:port-schedule:view' WHEN 5102182 THEN 'platform:firstleg:port-schedule:create' WHEN 5102183 THEN 'platform:firstleg:port-schedule:edit' END) + OR id=5102181 OR (permission_code LIKE 'platform:firstleg:port-schedule:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102180 AND 5102183)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT IN (5102180,5102182,5102183))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期港务船期权限迁移失败:5102180-5102183 ID 或权限码被其他语义占用'; @@ -9665,7 +9764,6 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102180,0,'PLATFORM','MENU',5102000,'platform:firstleg:port-schedule:list','港务船期','/first-leg/port-schedules','GET',120,'ACTIVE',1,0), - (5102181,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:view','查看港务船期',NULL,'GET',10,'ACTIVE',1,0), (5102182,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:create','新增港务船期',NULL,'POST',20,'ACTIVE',1,0), (5102183,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:edit','更新港务船期',NULL,'PUT',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), @@ -10108,7 +10206,7 @@ BEGIN OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102170 AND 5102175 AND permission_code LIKE 'platform:firstleg:customs:%') <> 6 OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' - AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 4 THEN + AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' @@ -10123,7 +10221,7 @@ BEGIN AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%' OR p.permission_code LIKE 'platform:firstleg:customs:%' OR p.permission_code LIKE 'platform:firstleg:port-schedule:%' - OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 38 THEN + OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期角色授权未完整写入'; END IF; END$$ @@ -10233,15 +10331,23 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空', container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0', - carrier_code VARCHAR(16) NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德', + carrier_code VARCHAR(16) NOT NULL COMMENT '船公司标准编码;OTHER表示人工跟踪', tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', - access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', - status VARCHAR(24) NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果', - window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间', - attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次', - next_allowed_at DATETIME(3) NOT NULL COMMENT '频控规则允许的下次查询时间', + access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式;MANUAL表示仅人工补录节点', + status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED', + trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', + latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', + latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间', + stopped_at DATETIME(3) NULL COMMENT '任务停止时间', + stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', + lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前正常查询槽位内的连续技术失败次数', + window_started_at DATETIME(3) NOT NULL COMMENT '当前正常查询槽位时间', + attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前正常查询槽位已调用次数;最多3次', + next_allowed_at DATETIME(3) NULL COMMENT '下一正常槽位或槽内技术退避时间;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -10262,7 +10368,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( PRIMARY KEY (id), UNIQUE KEY uk_carrier_schedule_query_no (query_no), UNIQUE KEY uk_carrier_schedule_query_scope - (customer_id,ocean_booking_id,container_scope_id,carrier_code,tracking_type,tracking_no), + (customer_id,ocean_booking_id,container_scope_id), KEY idx_carrier_schedule_query_due (status,next_allowed_at,id), KEY idx_carrier_schedule_query_customer (customer_id,updated_at,id), KEY idx_carrier_schedule_query_expiry (expires_at,id), @@ -10271,24 +10377,28 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id), CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0), CONSTRAINT chk_carrier_schedule_query_carrier CHECK - (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE')), + (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_carrier_schedule_query_tracking CHECK (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API','MANUAL')), CONSTRAINT chk_carrier_schedule_query_status CHECK - (status IN ('READY','MANUAL_REQUIRED','SUCCESS')), + (status IN ('READY','MANUAL_REQUIRED','STOPPED')), + CONSTRAINT chk_carrier_schedule_query_node CHECK + (latest_standard_node IS NULL OR latest_standard_node IN + ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT', + 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) -) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; +) ENGINE=InnoDB COMMENT='六家船公司固定槽位与技术退避查询任务'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询尝试ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前三天窗口内的尝试序号;取值1至3', + window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前正常查询槽位内的调用序号;取值1至3', carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', - attempt_status VARCHAR(24) NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败', + attempt_status VARCHAR(24) NOT NULL COMMENT '供应商调用结果', coverage_gap TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在自动化覆盖缺口:0否、1是', coverage_gap_code VARCHAR(64) NULL COMMENT '自动化覆盖缺口编码', manual_reason VARCHAR(1000) NULL COMMENT '转人工处理原因', @@ -10302,13 +10412,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK - (attempt_status IN ('MANUAL_REQUIRED','SUCCESS','FAILED')) + (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - action VARCHAR(24) NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果', + action VARCHAR(24) NOT NULL COMMENT '查询任务业务历史动作', from_version INT UNSIGNED NULL COMMENT '变更前任务版本;创建时为空', to_version INT UNSIGNED NOT NULL COMMENT '变更后任务版本', reason VARCHAR(1000) NULL COMMENT '操作原因或说明', @@ -10320,9 +10430,62 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( PRIMARY KEY (id), KEY idx_carrier_schedule_history_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), - CONSTRAINT chk_carrier_schedule_history_action CHECK (action IN ('CREATE','ATTEMPT','MANUAL_RESULT')) + CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')) ) ENGINE=InnoDB COMMENT='船司查询任务不可变历史'; +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', + provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码', + standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点', + node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + estimated_at DATETIME(3) NULL COMMENT '供应商预计发生时间', + actual_at DATETIME(3) NULL COMMENT '供应商确认实际发生时间', + location_code VARCHAR(32) NULL COMMENT '节点地点编码', + location_name VARCHAR(128) NULL COMMENT '节点地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key), + KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), + CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_node_standard CHECK + (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT', + 'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), + CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60,70,80,90,100)) +) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实'; + +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '船节点异常事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', + provider_status VARCHAR(128) NOT NULL COMMENT '供应商原始异常状态', + exception_code VARCHAR(32) NOT NULL COMMENT '平台异常编码', + active TINYINT(1) NOT NULL COMMENT '1异常发生;0异常解除', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + location_code VARCHAR(32) NULL COMMENT '地点编码', + location_name VARCHAR(128) NULL COMMENT '地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_exception_event (query_id,event_key), + KEY idx_carrier_schedule_exception_timeline (query_id,observed_at,id), + CONSTRAINT fk_carrier_schedule_exception_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_exception_code CHECK + (exception_code IN ('CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD')), + CONSTRAINT chk_carrier_schedule_exception_active CHECK (active IN (0,1)) +) ENGINE=InnoDB COMMENT='船节点异常发生与解除不可变事实'; + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID', query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', @@ -10341,6 +10504,26 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( CONSTRAINT chk_carrier_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) ) ENGINE=InnoDB COMMENT='船司查询写操作幂等与请求摘要'; +-- CREATE TABLE IF NOT EXISTS 不会更新已有表的 CHECK。历史开发库仍可能只允许 +-- CREATE/ATTEMPT/MANUAL_RESULT,必须在人工节点和自动节点写入前收敛为当前动作集。 +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; +DELIMITER $$ +CREATE PROCEDURE migrate_carrier_schedule_history_action_check() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query_history' + AND constraint_name='chk_carrier_schedule_history_action' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query_history DROP CHECK chk_carrier_schedule_history_action; + END IF; + ALTER TABLE fl_carrier_schedule_query_history + ADD CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')); +END$$ +DELIMITER ; +CALL migrate_carrier_schedule_history_action_check(); +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; + DROP TRIGGER IF EXISTS trg_carrier_schedule_query_before_insert; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW @@ -10363,6 +10546,10 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_delete; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_delete; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$ @@ -10372,25 +10559,41 @@ CREATE TRIGGER trg_carrier_schedule_history_before_update BEFORE UPDATE ON fl_ca FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可修改'; END$$ CREATE TRIGGER trg_carrier_schedule_history_before_delete BEFORE DELETE ON fl_carrier_schedule_query_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_update BEFORE UPDATE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_delete BEFORE DELETE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可删除'; END$$ DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); + DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; DELIMITER $$ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102184 AND 5102188 + WHERE (id IN (5102184,5102186,5102188) AND permission_code <> CASE id WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list' - WHEN 5102185 THEN 'platform:firstleg:port-schedule-query:view' WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create' - WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt' WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual' END) + OR id IN (5102185,5102187) OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%' - AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT BETWEEN 5102184 AND 5102188)) + AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102188))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用'; @@ -10405,9 +10608,7 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0), - (5102185,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:view','查看船司任务',NULL,'GET',50,'ACTIVE',1,0), (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0), - (5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0), (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), @@ -10428,12 +10629,13 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_firstleg' AND table_name IN ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', - 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation')) <> 4 THEN + 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation', + 'fl_carrier_schedule_node_fact','fl_carrier_schedule_exception_fact')) <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188 - AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 5 THEN + WHERE tenant_id=0 AND id IN (5102184,5102186,5102188) + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; END IF; IF (SELECT COUNT(*) @@ -10442,7 +10644,7 @@ BEGIN JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND permission.tenant_id=0 - AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 10 THEN + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; END IF; END$$ @@ -10450,6 +10652,172 @@ DELIMITER ; CALL assert_carrier_schedule_query_schema(); DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema; +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns; +DELIMITER $$ +CREATE PROCEDURE migrate_carrier_schedule_retry_columns() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='trigger_source') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' + COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='latest_standard_node') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='latest_node_observed_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='stopped_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN stopped_at DATETIME(3) NULL COMMENT '任务停止时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='stop_reason') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN stop_reason VARCHAR(500) NULL COMMENT '任务停止原因'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='lease_owner') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='lease_until') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='consecutive_failures') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位内的连续技术失败次数'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='window_started_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN window_started_at DATETIME(3) NULL COMMENT '当前正常查询槽位时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='attempt_count') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位已调用次数;最多3次'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='next_allowed_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN next_allowed_at DATETIME(3) NULL + COMMENT '下一正常槽位或槽内技术退避时间;停止后为空'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND index_name='idx_carrier_schedule_query_due') THEN + ALTER TABLE fl_carrier_schedule_query + ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id); + END IF; +END$$ +DELIMITER ; +CALL migrate_carrier_schedule_retry_columns(); +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns; + +-- CREATE TABLE IF NOT EXISTS 不会替换历史 CHECK 与唯一索引。统一收敛到当前 +-- READY/MANUAL_REQUIRED/STOPPED、五种访问方式、十个实际节点和每业务范围唯一任务。 +ALTER TABLE fl_carrier_schedule_query + DROP CHECK chk_carrier_schedule_query_carrier, + DROP CHECK chk_carrier_schedule_query_access, + DROP CHECK chk_carrier_schedule_query_status; +DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check; +DELIMITER $$ +CREATE PROCEDURE drop_legacy_carrier_schedule_node_check() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND constraint_name='chk_carrier_schedule_query_node' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query DROP CHECK chk_carrier_schedule_query_node; + END IF; +END$$ +DELIMITER ; +CALL drop_legacy_carrier_schedule_node_check(); +DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check; +ALTER TABLE fl_carrier_schedule_query + ADD CONSTRAINT chk_carrier_schedule_query_carrier CHECK + (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), + ADD CONSTRAINT chk_carrier_schedule_query_access CHECK + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API','MANUAL')), + ADD CONSTRAINT chk_carrier_schedule_query_status CHECK + (status IN ('READY','MANUAL_REQUIRED','STOPPED')), + ADD CONSTRAINT chk_carrier_schedule_query_node CHECK + (latest_standard_node IS NULL OR latest_standard_node IN + ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT', + 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), + DROP INDEX uk_carrier_schedule_query_scope, + ADD UNIQUE KEY uk_carrier_schedule_query_scope + (customer_id,ocean_booking_id,container_scope_id); + +SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00'); +SET @carrier_schedule_next = CASE + WHEN TIME(@carrier_schedule_now) < '09:00:00' THEN DATE(@carrier_schedule_now) + INTERVAL 9 HOUR + WHEN TIME(@carrier_schedule_now) < '12:30:00' THEN DATE(@carrier_schedule_now) + INTERVAL 12 HOUR + INTERVAL 30 MINUTE + WHEN TIME(@carrier_schedule_now) < '16:00:00' THEN DATE(@carrier_schedule_now) + INTERVAL 16 HOUR + ELSE DATE(@carrier_schedule_now + INTERVAL 1 DAY) + INTERVAL 9 HOUR + END; +UPDATE fl_carrier_schedule_query + SET next_allowed_at=@carrier_schedule_next,window_started_at=@carrier_schedule_next, + attempt_count=0,consecutive_failures=0,lease_owner=NULL,lease_until=NULL + WHERE status IN ('READY','MANUAL_REQUIRED'); +UPDATE fl_carrier_schedule_query + SET next_allowed_at=NULL, + window_started_at=COALESCE(window_started_at,@carrier_schedule_next), + attempt_count=0,consecutive_failures=0,lease_owner=NULL,lease_until=NULL + WHERE status='STOPPED'; +UPDATE fl_carrier_schedule_query + SET window_started_at=COALESCE(window_started_at,@carrier_schedule_next), + attempt_count=LEAST(COALESCE(attempt_count,0),3), + consecutive_failures=COALESCE(consecutive_failures,0); + +ALTER TABLE fl_carrier_schedule_query + MODIFY COLUMN lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', + MODIFY COLUMN lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', + MODIFY COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位内的连续技术失败次数', + MODIFY COLUMN window_started_at DATETIME(3) NOT NULL COMMENT '当前正常查询槽位时间', + MODIFY COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位已调用次数;最多3次', + MODIFY COLUMN next_allowed_at DATETIME(3) NULL + COMMENT '下一正常槽位或槽内技术退避时间;停止后为空'; + +DROP PROCEDURE IF EXISTS ensure_carrier_schedule_retry_check; +DELIMITER $$ +CREATE PROCEDURE ensure_carrier_schedule_retry_check() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND constraint_name='chk_carrier_schedule_query_attempt' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query + ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3); + END IF; +END$$ +DELIMITER ; +CALL ensure_carrier_schedule_retry_check(); +DROP PROCEDURE IF EXISTS ensure_carrier_schedule_retry_check; + -- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql -- 三期仓库常用系统角色:仓库操作员、仓库拣货员、仓库理货员。 -- 本脚本只组合已经存在的权限资源,需在三期 Firstleg/IAM 权限脚本之后执行,可重复部署。 @@ -10593,6 +10961,7 @@ DELETE role_permission WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') + AND NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate') AND expected.permission_code IS NULL; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) @@ -11837,10 +12206,12 @@ CALL queue_table_comment('baiyue_finance', 'fin_receipt_bill', '人工收款对 CALL queue_table_comment('baiyue_finance', 'fin_subject', '平台财务主体;首期仅一个有效主体'); CALL queue_table_comment('baiyue_firstleg', 'fl_assignment_history', '头程任务指派历史表:不可变记录每次负责人变更及原因,用于追踪和审计'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_fba_rate', '船司FBA目的仓价格版本'); -CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query', '六家船公司官网查询任务与三天窗口频控'); +CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query', '六家船公司固定槽位与技术退避查询任务'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', '船司官网查询尝试不可变审计'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', '船司查询任务不可变历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', '船司查询写操作幂等与请求摘要'); +CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', '供应商标准船节点不可变事实'); +CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', '船节点异常发生与解除不可变事实'); CALL queue_table_comment('baiyue_firstleg', 'fl_consumed_event', '头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_booking', '平台货柜预订资料;预装状态唯一来源为装柜计划'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货柜清关、DO与PU文件关联'); @@ -12071,7 +12442,7 @@ CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'retry_count', CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'source_region', '来源区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'status', '消费状态'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'target_region', '目标区域'); -CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'tenant_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); +CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'customer_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'trace_id', '链路标识'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_id', '聚合标识'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_type', '聚合类型'); @@ -12096,7 +12467,7 @@ CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'retry_count', CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'source_region', '来源区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'status', '投递状态'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'target_region', '目标区域'); -CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'tenant_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); +CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'customer_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'trace_id', '链路标识'); CALL queue_column_comment('baiyue_file', 'file_download_log', 'client_ip', '客户端IP'); CALL queue_column_comment('baiyue_file', 'file_download_log', 'downloaded_at', '下载时间'); @@ -12421,8 +12792,8 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'updated_at' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'updated_by', '最后修改人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'weight_from_kg', '区域阶梯起始计费重量KG'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'access_mode', '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'attempt_count', '当前查询窗口已执行次数;最多3次'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'access_mode', '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'attempt_count', '当前正常查询槽位已调用次数;最多3次'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'carrier_code', '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'container_booking_id', '指定查询的柜次ID;整票订舱查询时为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'container_scope_id', '柜次唯一作用域计算值;整票订舱查询固定为0'); @@ -12438,23 +12809,31 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_error_message', '最近查询失败错误说明'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_success_at', '最近一次取得有效结果时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'manual_reason', '需要人工查询或录入的原因'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'next_allowed_at', '频控规则允许的下次查询时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'next_allowed_at', '下一正常槽位或槽内技术退避时间;停止后为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'ocean_booking_id', '关联海运订舱ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'official_url', '船公司官网查询入口地址快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'query_no', '船司官网查询任务编号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'remark', '平台内部备注'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'result_port_schedule_id', '最近有效结果对应的船期记录ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'source_updated_at', '船公司官网展示的结果更新时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务操作状态:READY、MANUAL_REQUIRED、STOPPED'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'trigger_source', '任务触发来源:OUTBOUND_CONFIRM或MANUAL'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_standard_node', '最近实际标准船节点;预计事实不推进'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_node_observed_at', '最近实际标准节点发生时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stopped_at', '任务停止时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stop_reason', '任务停止原因'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_owner', '当前调度租约持有者'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_until', '当前调度租约过期时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'consecutive_failures', '当前正常查询槽位内的连续技术失败次数'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_no', '提交至船公司官网的跟踪号码'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_type', '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_at', '任务最后更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_by', '最后修改操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_by_name', '最后修改操作人姓名快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'version', '乐观锁版本号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'window_started_at', '当前三天查询窗口起始时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'window_started_at', '当前正常查询槽位时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'access_mode', '本次尝试的官网访问方式快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'attempt_status', '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'attempt_status', '供应商调用结果'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'carrier_code', '本次尝试的船公司编码快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'coverage_gap', '是否存在自动化覆盖缺口:0否、1是'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'coverage_gap_code', '自动化覆盖缺口编码'); @@ -12467,8 +12846,8 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'operator_id', '执行本次尝试的操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'operator_name', '执行操作人姓名快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'query_id', '所属船司官网查询任务ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'window_attempt_no', '当前三天窗口内的尝试序号;取值1至3'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'action', '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'window_attempt_no', '当前正常查询槽位内的调用序号;取值1至3'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'action', '查询任务业务历史动作'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'after_snapshot', '变更后任务数据快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'before_snapshot', '变更前任务数据快照;创建时为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'created_at', '历史记录创建时间'); @@ -12489,6 +12868,36 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operatio CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'request_hash', '规范化请求内容SHA-256摘要;用于识别同键不同请求'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'result_json', '成功结果JSON快照;供幂等重放返回'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'status', '处理状态:PROCESSING处理中、SUCCESS成功'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'id', '标准船节点事实ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'query_id', '所属查询任务ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'event_key', '供应商编码与供应商事件ID组成的幂等键'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_code', '轨迹供应商编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_event_id', '供应商稳定事件ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_node_code', '供应商原始节点编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'standard_node', '标准船节点'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'node_sequence', '标准节点单调序号'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'observed_at', '供应商观测时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'estimated_at', '供应商预计发生时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'actual_at', '供应商确认实际发生时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'location_code', '节点地点编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'location_name', '节点地点名称'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'source_reference', '可审计来源引用'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'raw_payload_hash', '原始响应SHA-256;不保存敏感原文'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'created_at', '事实入库时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'id', '船节点异常事实ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'query_id', '所属查询任务ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'event_key', '供应商异常事件幂等键'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_code', '供应商编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_event_id', '供应商稳定事件ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_status', '供应商原始异常状态'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'exception_code', '平台异常编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'active', '1异常发生;0异常解除'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'observed_at', '供应商观测时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'location_code', '地点编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'location_name', '地点名称'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'source_reference', '可审计来源引用'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'raw_payload_hash', '原始响应SHA-256'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'created_at', '事实入库时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'consumed_at', '首次成功消费时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_id', '已消费领域事件ID,与生产端Outbox事件ID一致'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_type', '已消费事件类型'); @@ -13476,6 +13885,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_mojia_measurement_receipt', 'up CALL queue_column_comment('baiyue_firstleg', 'fl_mojia_measurement_receipt', 'weight_kg', '设备实测重量kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_mojia_measurement_receipt', 'width_cm', '设备实测宽度cm'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'bill_of_lading_no', '提单号'); +CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'carrier_code', '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_id', '订舱代理逻辑ID快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_name', '订舱代理名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_no', '平台海运订舱编号'); @@ -14343,6 +14753,7 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_opera CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'target_business_id', '目标子入仓单ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'tenant_id', '仓储公共资源域,固定为0'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'address_line', '详细地址'); +CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code', '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'city', '城市'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'contact_email', '联系邮箱'); CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'contact_name', '联系人姓名'); @@ -14400,7 +14811,7 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'updated_by', '最 CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_code', '平台内唯一仓库编码'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_name', '仓库名称'); -CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_type', '公共仓库类型:DOMESTIC、FBA、OVERSEAS'); +CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_type', '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse_address', 'address_line1', '主地址行'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse_address', 'address_line2', '补充地址行'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse_address', 'address_type', '地址类型,例如OPERATING或RETURN'); @@ -14440,6 +14851,37 @@ BEGIN 'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_iam' AND table_name='iam_tenant' + AND column_name='dimension_initialization_status') <> 1 + OR (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_iam' + AND table_name='iam_customer_dimension_initialization_task') <> 1 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_iam' + AND table_name='iam_customer_dimension_initialization_task' + AND column_name IN ('tenant_id','initialization_key','action','status','attempt_count', + 'next_retry_at','lease_owner','lease_until','last_error_code','last_error_message', + 'task_version')) <> 11 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:IAM客户默认尺寸后台初始化状态或可靠任务结构不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' + AND column_name IN ('trigger_source','latest_standard_node','latest_node_observed_at', + 'stopped_at','stop_reason','lease_owner','lease_until','consecutive_failures', + 'window_started_at','attempt_count','next_allowed_at')) <> 11 + OR (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' + AND index_name='idx_carrier_schedule_query_due' + AND column_name IN ('status','next_allowed_at','id')) <> 3 + OR (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' + AND constraint_name='chk_carrier_schedule_query_attempt' + AND constraint_type='CHECK') <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整'; + END IF; IF EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema IN ('baiyue_auth','baiyue_iam','baiyue_order','baiyue_warehouse', @@ -14867,10 +15309,9 @@ VALUES (5105019, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:cost:create', '登记成本', NULL, 'POST', 100, 'ACTIVE', 0), (5105020, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:view', '查看客户账期', NULL, 'GET', 110, 'ACTIVE', 0), (5105021, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:update', '修改客户账期', NULL, 'PUT', 120, 'ACTIVE', 0), - (5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), - (5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), - (5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), - (5205012, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0), + (5305000, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), + (5305010, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), + (5305011, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), (5305012, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; @@ -15003,11 +15444,6 @@ WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ( 'platform:finance:settlement-ap:create', 'platform:finance:payment:create', 'platform:finance:settlement:void') ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); -INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM baiyue_iam.iam_permission - WHERE tenant_id=10001 AND app_code='MERCHANT' AND (permission_code='merchant:finance:statement' OR permission_code LIKE 'merchant:finance:%') -ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); - -- 开发期 list-only 收口:先移除角色关联并停用旧只读资源,再执行最终 Finance 断言。 DELETE rp FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id @@ -15080,11 +15516,17 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='ocean_booking_requirement_id') THEN ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID' AFTER ocean_booking_id; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='customer_id') THEN + ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '已关联真实订舱指定的客户范围' AFTER ocean_booking_requirement_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='customer_name') THEN + ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照' AFTER customer_id; + END IF; UPDATE baiyue_firstleg.fl_container_booking c JOIN baiyue_firstleg.fl_ocean_booking_container_allocation a ON a.container_booking_id=c.id JOIN baiyue_firstleg.fl_ocean_booking b ON b.id=a.ocean_booking_id - SET c.ocean_booking_id=b.id,c.ocean_booking_requirement_id=a.requirement_id,c.channel_code=COALESCE(c.channel_code,b.channel_code),c.channel_name=COALESCE(c.channel_name,b.channel_name) + SET c.ocean_booking_id=b.id,c.ocean_booking_requirement_id=a.requirement_id,c.channel_code=COALESCE(c.channel_code,b.channel_code),c.channel_name=COALESCE(c.channel_name,b.channel_name),c.customer_id=COALESCE(c.customer_id,b.customer_id),c.customer_name=COALESCE(c.customer_name,b.customer_name) WHERE c.ocean_booking_id IS NULL OR c.ocean_booking_requirement_id IS NULL - OR c.channel_code IS NULL OR c.channel_name IS NULL; + OR c.channel_code IS NULL OR c.channel_name IS NULL OR c.customer_id IS NULL OR c.customer_name IS NULL; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_pick_task' AND column_name='container_booking_id') THEN ALTER TABLE baiyue_firstleg.fl_loading_pick_task ADD COLUMN container_booking_id BIGINT UNSIGNED NULL COMMENT '稳定柜次ID' AFTER loading_plan_id; END IF; @@ -15377,7 +15819,7 @@ CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id; - IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增'; END IF; END$$ @@ -15388,7 +15830,7 @@ CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id; - IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改'; END IF; END$$ @@ -15467,22 +15909,18 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:receivable-statistics:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export','platform:download:list'); UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:edit',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; -INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) -SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' -WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') - AND p.permission_code='merchant:finance:statement:confirm'; - INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT' WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 - AND p.permission_code='merchant:finance:statement:confirm' + AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list', + 'merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0; INSERT INTO baiyue_iam.iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) VALUES - (5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; @@ -15698,11 +16136,8 @@ BEGIN IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 3 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 查询与确认权限不完整'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code='merchant:finance:statement:confirm' AND status='ACTIVE' AND deleted=0) <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局对账单确认权限不完整'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement','merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局 Finance 权限模板不完整'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整'; @@ -15710,22 +16145,415 @@ BEGIN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.status='ACTIVE' AND p.deleted=0 AND p.permission_code IN ('platform:download:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export')) <> 7 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 导出和下载中心授权不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 4 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 查询与确认授权不完整'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code='merchant:finance:statement:confirm' AND p.status='ACTIVE' AND p.deleted=0) <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员缺少对账单确认权限'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.id=4000100 AND r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员 Finance 模板授权不完整'; END IF; END$$ DELIMITER ; CALL assert_finance_permissions(); DROP PROCEDURE IF EXISTS assert_finance_permissions; +-- IAM 跨区安全事件可靠发送箱。来源:iam/src/main/resources/db/security-event-outbox.sql +USE baiyue_iam; +CREATE TABLE IF NOT EXISTS iam_security_subject_version ( + auth_user_id BIGINT NOT NULL COMMENT '认证账号ID', + permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (auth_user_id) +) ENGINE=InnoDB COMMENT='安全主体权限版本'; + +CREATE TABLE IF NOT EXISTS iam_security_tenant_version ( + tenant_id BIGINT NOT NULL COMMENT '客户ID', + status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (tenant_id) +) ENGINE=InnoDB COMMENT='客户状态事件版本'; + +CREATE TABLE IF NOT EXISTS 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) NULL COMMENT '租约持有者', + lease_until DATETIME(3) NULL COMMENT '租约到期时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次错误', + sent_at DATETIME(3) 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 COMMENT='IAM可靠安全事件本地发送箱'; + +DROP PROCEDURE IF EXISTS assert_iam_security_event_schema; +DELIMITER $$ +CREATE PROCEDURE assert_iam_security_event_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_iam' + AND table_name IN ('iam_security_subject_version','iam_security_tenant_version','iam_security_event_outbox')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM安全事件表不完整'; + END IF; + IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema='baiyue_iam' AND table_name='iam_security_event_outbox' + AND index_name IN ('PRIMARY','idx_security_outbox_publish','idx_security_outbox_aggregate')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM安全事件索引不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_iam_security_event_schema(); +DROP PROCEDURE IF EXISTS assert_iam_security_event_schema; + USE baiyue_firstleg; +DROP PROCEDURE IF EXISTS add_container_empty_report_fields; +DELIMITER $$ +CREATE PROCEDURE add_container_empty_report_fields() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)' AFTER actual_warehouse_arrival_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='unloading_photo_uploaded_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)' AFTER unloading_completed_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)' AFTER unloading_photo_uploaded_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)' AFTER estimated_empty_reported_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='empty_report_source_version') THEN + ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at; + END IF; +END$$ +DELIMITER ; +CALL add_container_empty_report_fields(); +DROP PROCEDURE IF EXISTS add_container_empty_report_fields; + +CREATE TABLE IF NOT EXISTS fl_container_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID', event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本', aggregate_id VARCHAR(64) NOT NULL COMMENT '货柜聚合ID', + aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '货柜聚合版本', payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要', + payload_json JSON NOT NULL COMMENT '原始事件负载', status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at DATETIME(3) NOT NULL COMMENT '接收时间(UTC)', processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)', + PRIMARY KEY (event_id), KEY idx_container_event_aggregate (aggregate_id,aggregate_version) +) ENGINE=InnoDB COMMENT='海外货柜事件消费Inbox'; + -- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。 ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; +ALTER TABLE fl_inbound_outbox + MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; +-- 四期海外权限统一目录(来源:iam/db/lastleg-permissions.sql) +-- 四期 Admin 海外权限只使用 platform:overseas-*;列表权限包含详情只读。 +DELETE rp FROM baiyue_iam.iam_role_permission rp +JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id +WHERE p.permission_code LIKE 'platform:lastleg:%'; +UPDATE baiyue_iam.iam_permission SET status='INACTIVE', deleted=1 +WHERE permission_code LIKE 'platform:lastleg:%'; + +INSERT INTO baiyue_iam.iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0), + (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0), + (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), + (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), + (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), + (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), + (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0), + (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0), + (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,0), + (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), + (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), + (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), + (5107030,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:pallet:list','海外托盘','/overseas-warehouse/pallets','GET',730,'ACTIVE',1,0), + (5107031,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:create','创建海外托盘',NULL,'POST',731,'ACTIVE',1,0), + (5107032,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:edit','编辑海外托盘',NULL,'PUT',732,'ACTIVE',1,0), + (5107033,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:delete','删除海外托盘',NULL,'DELETE',733,'ACTIVE',1,0), + (5107040,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inventory:list','海外库存','/overseas-warehouse/inventory','GET',740,'ACTIVE',1,0), + (5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,0), + (5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,0), + (5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,0), + (5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,0), + (5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,0), + (5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,0), + (5107053,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:status','变更海外仓状态',NULL,'PATCH',753,'ACTIVE',1,0), + (5107060,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:location:list','海外库位','/overseas-warehouse/locations','GET',760,'ACTIVE',1,0), + (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), + (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), + (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), + (5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0), + (5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0), + (5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0), + (5107110,0,'PLATFORM','MENU',0,'platform:overseas-business:truck:list','本土卡车','/overseas-business/trucks','GET',810,'ACTIVE',1,0), + (5107111,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:create','创建本土卡车单',NULL,'POST',811,'ACTIVE',1,0), + (5107112,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:edit','编辑本土卡车单',NULL,'PUT',812,'ACTIVE',1,0), + (5107113,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:status','变更本土卡车状态',NULL,'PATCH',813,'ACTIVE',1,0), + (5107120,0,'PLATFORM','MENU',0,'platform:overseas-business:looseboard:list','本土散板','/overseas-business/looseboards','GET',820,'ACTIVE',1,0), + (5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,0), + (5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,0), + (5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,0), + (5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,0), + (5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,0), + (5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,0), + (5107203,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:status','变更海外仓报价状态',NULL,'PATCH',903,'ACTIVE',1,0), + (5107204,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:export','导出海外仓报价',NULL,'GET',904,'ACTIVE',1,0), + (5107210,0,'PLATFORM','MENU',0,'platform:overseas-master-data:exchange-rate:list','月度汇率','/overseas-master-data/exchange-rates','GET',910,'ACTIVE',1,0), + (5107211,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:create','创建月度汇率',NULL,'POST',911,'ACTIVE',1,0), + (5107212,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:edit','编辑月度汇率',NULL,'PUT',912,'ACTIVE',1,0), + (5107213,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:status','变更月度汇率状态',NULL,'PATCH',913,'ACTIVE',1,0), + (5107214,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:export','导出月度汇率',NULL,'GET',914,'ACTIVE',1,0), + (5107220,0,'PLATFORM','MENU',0,'platform:overseas-master-data:vehicle:list','车辆主数据','/overseas-master-data/vehicles','GET',920,'ACTIVE',1,0), + (5107221,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:create','创建车辆主数据',NULL,'POST',921,'ACTIVE',1,0), + (5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0), + (5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0), + (5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), + http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM baiyue_iam.iam_role role +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.id BETWEEN 5107000 AND 5107299 AND permission.permission_code<>'lastleg:pda:operate' +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +DELETE role_permission FROM baiyue_iam.iam_role_permission role_permission +JOIN baiyue_iam.iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id +JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id +WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'); + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM baiyue_iam.iam_role role +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code='lastleg:pda:operate' +WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS baiyue_iam.assert_overseas_pda_picker_permission; +DELIMITER $$ +CREATE PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission() +BEGIN + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission grant_row + JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权'; + END IF; + IF EXISTS (SELECT 1 FROM baiyue_iam.iam_role_permission grant_row + JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权'; + END IF; +END$$ +DELIMITER ; +CALL baiyue_iam.assert_overseas_pda_picker_permission(); +DROP PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission; + +-- 四期仓库后台角色严格最小授权:list 同时覆盖列表与详情,只读角色不得获得任何写权限。 +USE baiyue_iam; +DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission; +CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission ( + role_code VARCHAR(64) NOT NULL, + permission_code VARCHAR(160) NOT NULL, + PRIMARY KEY (role_code, permission_code) +); +INSERT INTO tmp_phase4_warehouse_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:purchase:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:truck:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:looseboard:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:purchase:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:truck:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:looseboard:list'); + +DELETE grant_row FROM baiyue_iam.iam_role_permission grant_row +JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id +JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id +LEFT JOIN tmp_phase4_warehouse_role_permission expected + ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY') + AND permission.permission_code LIKE 'platform:overseas-%' + AND expected.permission_code IS NULL; + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM tmp_phase4_warehouse_role_permission expected +JOIN baiyue_iam.iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code + AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS baiyue_iam.assert_phase4_warehouse_role_minimum; +DELIMITER $$ +CREATE PROCEDURE baiyue_iam.assert_phase4_warehouse_role_minimum() +BEGIN + IF (SELECT COUNT(*) FROM tmp_phase4_warehouse_role_permission) <> 20 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限矩阵数量异常'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission + WHERE permission_code REGEXP ':(create|edit|status|export|adjust|delete|review)$') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库只读角色错误获得写权限'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission expected + LEFT JOIN baiyue_iam.iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 + LEFT JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0 + LEFT JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.tenant_id=0 + AND grant_row.role_id=role.id AND grant_row.permission_id=permission.id + WHERE grant_row.role_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL baiyue_iam.assert_phase4_warehouse_role_minimum(); +DROP PROCEDURE baiyue_iam.assert_phase4_warehouse_role_minimum; +DROP TEMPORARY TABLE tmp_phase4_warehouse_role_permission; + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM baiyue_iam.iam_role role +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:inbound-order:fba-appointment', + 'platform:overseas-warehouse:inbound-order:smart-dispatch' + ) +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM baiyue_iam.iam_role role +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:last-mile-order:edit', + 'platform:overseas-warehouse:inbound-order:create' + ) +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +-- 海外智能排车草稿锁:拖入即锁定,移除或关闭草稿即释放。 +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', + active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空', + draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识', + operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空', + released_at DATETIME(3) 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 (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), + KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status) +) ENGINE=InnoDB COMMENT='派车托盘排他锁'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_token'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_token VARCHAR(64) NULL COMMENT ''智能排车草稿随机标识'' AFTER dispatch_order_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='operator_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN operator_id VARCHAR(128) NULL COMMENT ''草稿锁操作人ID'' AFTER draft_token')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='expires_at'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN expires_at DATETIME(3) NULL COMMENT ''草稿锁失效时间,正式派车锁为空'' AFTER locked_at')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空''')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 尾程库存余额按客户入仓单与海外目的仓汇总箱数,SKU仅保留为快照。 +SET @ddl = (SELECT CASE + WHEN EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension' AND column_name='sku_code') + THEN 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension, ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)' + WHEN NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension') + THEN 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)' + ELSE 'SELECT 1' END); +PREPARE lastleg_inventory_dimension_stmt FROM @ddl; EXECUTE lastleg_inventory_dimension_stmt; DEALLOCATE PREPARE lastleg_inventory_dimension_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_inventory_balance' AND column_name='sku_code'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''', + 'SELECT 1')); +PREPARE lastleg_inventory_dimension_stmt FROM @ddl; EXECUTE lastleg_inventory_dimension_stmt; DEALLOCATE PREPARE lastleg_inventory_dimension_stmt; +ALTER TABLE ll_inventory_balance COMMENT='客户入仓单与海外目的仓维度实时库存余额'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_local_looseboard_order' AND column_name='lastleg_order_id'), 'SELECT 1', + 'ALTER TABLE ll_local_looseboard_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''关联尾程主订单ID'' AFTER customer_id')); +PREPARE lastleg_looseboard_tail_stmt FROM @ddl; EXECUTE lastleg_looseboard_tail_stmt; DEALLOCATE PREPARE lastleg_looseboard_tail_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_local_looseboard_order' AND index_name='uk_ll_local_looseboard_lastleg'), 'SELECT 1', + 'ALTER TABLE ll_local_looseboard_order ADD UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id)')); +PREPARE lastleg_looseboard_tail_stmt FROM @ddl; EXECUTE lastleg_looseboard_tail_stmt; DEALLOCATE PREPARE lastleg_looseboard_tail_stmt; + +-- IAM 客户提交即成功,默认尺寸通过持久化任务后台初始化;已有客户按历史事实标记 READY。 +USE baiyue_iam; +SET @customer_dimension_status_ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='iam_tenant' AND column_name='dimension_initialization_status'), + 'SELECT 1', + 'ALTER TABLE iam_tenant ADD COLUMN dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT ''READY'' COMMENT ''默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败'' AFTER status')); +PREPARE customer_dimension_status_stmt FROM @customer_dimension_status_ddl; +EXECUTE customer_dimension_status_stmt; +DEALLOCATE PREPARE customer_dimension_status_stmt; +CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( + tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', + operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', + initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', + action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING、SENDING、READY、FAILED、CANCELLED', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间', + lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间', + last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码', + last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID', + task_version INT 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 '任务更新时间', + completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间', + PRIMARY KEY (tenant_id), + KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), + KEY idx_customer_dimension_task_diagnosis (status,updated_at) +) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, - '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; + '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源、可靠审计及IAM安全事件结构校验通过' AS message; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql new file mode 100644 index 000000000..f7a4db970 --- /dev/null +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -0,0 +1,1079 @@ +-- 海外区域固定增量入口。空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线; +-- 本入口同时初始化海外 Event Bridge 独立库,避免国内/海外实例误共用 Inbox。 +-- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。 +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge_overseas; + +CREATE TABLE IF NOT EXISTS bridge_outbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', + retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', + lease_owner VARCHAR(128) NULL COMMENT '租约持有者', + lease_until DATETIME(3) NULL COMMENT '租约截止时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + delivered_at DATETIME(3) 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 COMMENT='海外跨区域事件待投递箱'; + +CREATE TABLE IF NOT EXISTS bridge_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', + lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', + retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) 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 COMMENT='海外跨区域事件已接收箱'; + +CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + attempt_no INT NOT NULL COMMENT '投递序号', + status VARCHAR(32) NOT NULL COMMENT '投递结果', + response_code VARCHAR(64) NULL COMMENT '对端响应码', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + started_at DATETIME(3) NOT NULL COMMENT '开始时间', + finished_at DATETIME(3) 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 COMMENT='海外跨区域事件投递尝试记录'; + +DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_event_bridge_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_event_bridge_overseas' + AND table_name IN ('bridge_outbox','bridge_inbox','bridge_delivery_attempt')) <> 3 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='海外区域初始化失败:Event Bridge 三张表未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_name, ':', index_name)) FROM information_schema.statistics + WHERE table_schema='baiyue_event_bridge_overseas' + AND ((table_name='bridge_outbox' AND index_name IN ('PRIMARY','uk_bridge_outbox_idempotency','idx_bridge_outbox_schedule')) + OR (table_name='bridge_inbox' AND index_name IN ('PRIMARY','uk_bridge_inbox_idempotency','idx_bridge_inbox_consume')) + OR (table_name='bridge_delivery_attempt' AND index_name IN ('PRIMARY','uk_bridge_attempt','idx_bridge_attempt_event')))) <> 9 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='海外区域初始化失败:Event Bridge 索引未完整创建'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_event_bridge_schema(); +DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema; + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_event_outbox ( + id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本', + event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', + locked_at DATETIME(3) NULL COMMENT '投递租约获取时间', locked_by VARCHAR(128) NULL COMMENT '投递租约持有者', + last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), + KEY idx_ll_event_outbox_publish (status,next_retry_at,created_at) +) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote ( + id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称', + warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照', + warehouse_name VARCHAR(128) NOT NULL COMMENT '合作仓名称快照', country_code VARCHAR(2) NOT NULL COMMENT '国家二字码快照', + provider_name VARCHAR(128) NOT NULL COMMENT '报价方', currency CHAR(3) NOT NULL COMMENT '币种', + effective_start DATE NOT NULL COMMENT '生效开始日期', effective_end DATE NOT NULL COMMENT '生效结束日期', + version_code VARCHAR(64) NOT NULL COMMENT '业务版本号', status VARCHAR(16) NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_warehouse_provider_version (warehouse_id,provider_name,version_code), + KEY idx_ll_quote_page (status,updated_at,id), KEY idx_ll_quote_effective (warehouse_id,provider_name,currency,effective_start,effective_end) +) ENGINE=InnoDB COMMENT='海外仓报价主表'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_fee ( + id BIGINT NOT NULL COMMENT '费用明细ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + item VARCHAR(128) NOT NULL COMMENT '费用项目', billing_unit VARCHAR(64) NOT NULL COMMENT '计费单位', + unit_price DECIMAL(18,4) NOT NULL COMMENT '单价', minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', + remark VARCHAR(500) NULL COMMENT '备注', PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_fee_sequence (quote_id,sequence_no), + KEY idx_ll_quote_fee_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价仓库费用'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_transit_price ( + id BIGINT NOT NULL COMMENT '中转价格ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + fba_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码', delivery_method VARCHAR(64) NOT NULL COMMENT '报价运输方式', + estimated_miles DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '预估英里数', price DECIMAL(18,4) NOT NULL COMMENT '报价金额', + minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', remark VARCHAR(500) NULL COMMENT '备注', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_transit_sequence (quote_id,sequence_no), + KEY idx_ll_quote_transit_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价中转价格明细'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate ( + id BIGINT NOT NULL COMMENT '月度汇率ID', rate_month CHAR(7) NOT NULL COMMENT '汇率月份,格式yyyy-MM', + base_currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '基准币种', quote_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '报价币种', + usd_to_cny_average DECIMAL(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_exchange_rate_month (rate_month), KEY idx_ll_exchange_rate_page (status,rate_month) +) ENGINE=InnoDB COMMENT='美元兑人民币月度平均汇率'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history ( + id BIGINT NOT NULL COMMENT '汇率变更历史ID', exchange_rate_id BIGINT NOT NULL COMMENT '月度汇率ID', + before_rate DECIMAL(18,8) NULL COMMENT '变更前汇率', after_rate DECIMAL(18,8) NOT NULL COMMENT '变更后汇率', + before_status VARCHAR(16) NULL COMMENT '变更前状态', after_status VARCHAR(16) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NOT NULL COMMENT '操作人', remark VARCHAR(1000) NULL COMMENT '变更说明', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY (id), KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at) +) ENGINE=InnoDB COMMENT='月度汇率变更历史'; + +CREATE TABLE IF NOT EXISTS ll_vehicle ( + id BIGINT NOT NULL COMMENT '车辆档案ID', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号', + model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升', + mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位', + phone VARCHAR(64) NULL COMMENT '司机或外派单位联系电话', region VARCHAR(16) NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS', + ownership VARCHAR(16) NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED', + status VARCHAR(32) NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', 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) +) ENGINE=InnoDB COMMENT='平台车辆主数据'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号', + purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称', + product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接', + purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', + currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', + purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', + inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', + domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', + overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', + review_remark VARCHAR(1000) NULL COMMENT '审核备注', review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', + review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), + UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key), + KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), + KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购订单'; + +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_idempotency_key' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER review_remark')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购操作流水'; + +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号', + transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址', + container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', + package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', + allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数', shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已出库托盘数', + total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + budget_amount DECIMAL(18,2) NULL COMMENT '预算金额', currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式', + requested_date DATE NOT NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态', remark VARCHAR(1000) NULL COMMENT '备注', + receipt_idempotency_key VARCHAR(255) NULL COMMENT '实物收货幂等键', receipt_request_hash CHAR(64) NULL COMMENT '实物收货请求哈希', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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) COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no), UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key), + UNIQUE KEY uk_ll_us_truck_receipt_key(customer_id,receipt_idempotency_key), KEY idx_ll_us_truck_order_list(customer_id,status,created_at), + KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='美国卡车运输订单'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( + id BIGINT NOT NULL COMMENT '托盘记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '业务托盘号', sequence_no INT NOT NULL COMMENT '订单内托盘序号', + source VARCHAR(32) NOT NULL COMMENT '托盘来源:订单计划或PDA超收', status VARCHAR(32) NOT NULL COMMENT '托盘状态:待收、已收或超收待审核', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘资源ID', package_count INT NULL COMMENT '托盘箱数', received_at DATETIME(3) 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(id), + UNIQUE KEY uk_ll_us_truck_order_pallet_no(customer_id,order_id,pallet_no), UNIQUE KEY uk_ll_us_truck_order_pallet_seq(customer_id,order_id,sequence_no), + KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status) +) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_us_truck_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_us_truck_order_status_history' AND column_name='operation_type'), 'SELECT 1', + 'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN operation_type VARCHAR(64) NOT NULL DEFAULT ''UPDATE'' COMMENT ''具名操作类型'' AFTER customer_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_us_truck_order_status_history' AND column_name='idempotency_key'), 'SELECT 1', + 'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_us_truck_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单幂等操作账'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', + transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', + transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', + container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', + total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', + contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式', + requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', + remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', 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 '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_local_looseboard_no(customer_id,order_no), UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key), + UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id), + KEY idx_ll_local_looseboard_list(customer_id,status,created_at), KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_local_looseboard_order' AND column_name='lastleg_order_id'), 'SELECT 1', + 'ALTER TABLE ll_local_looseboard_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''关联尾程主订单ID'' AFTER customer_id')); +PREPARE phase4_looseboard_tail_stmt FROM @ddl; EXECUTE phase4_looseboard_tail_stmt; DEALLOCATE PREPARE phase4_looseboard_tail_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_local_looseboard_order' AND index_name='uk_ll_local_looseboard_lastleg'), 'SELECT 1', + 'ALTER TABLE ll_local_looseboard_order ADD UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id)')); +PREPARE phase4_looseboard_tail_stmt FROM @ddl; EXECUTE phase4_looseboard_tail_stmt; DEALLOCATE PREPARE phase4_looseboard_tail_stmt; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='本土散板操作流水'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_local_looseboard_history' AND column_name='idempotency_key'), 'SELECT 1', + 'ALTER TABLE ll_local_looseboard_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账'; + +-- 尾程主订单状态历史补齐客户隔离键。代码写入 customer_id,已有表需显式升级; +-- 先允许 NULL 以便从主订单回填,再收紧为 NOT NULL,脚本可重复执行。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_loading_order_status_history' AND column_name='customer_id'), 'SELECT 1', + 'ALTER TABLE ll_loading_order_status_history ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id')); +PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt; +UPDATE ll_loading_order_status_history history +JOIN ll_order orders ON orders.id=history.order_id +SET history.customer_id=orders.customer_id +WHERE history.customer_id IS NULL; +ALTER TABLE ll_loading_order_status_history + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_loading_order_status_history' AND index_name='idx_ll_loading_history_customer_order'), 'SELECT 1', + 'ALTER TABLE ll_loading_order_status_history ADD KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)')); +PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt; + +-- 尾程订单明细实体、事件消费者和详情查询均使用 customer_id;已有开发库需从主订单回填。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_order_detail' AND column_name='customer_id'), 'SELECT 1', + 'ALTER TABLE ll_order_detail ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id')); +PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt; +UPDATE ll_order_detail detail +JOIN ll_order orders ON orders.id=detail.order_id +SET detail.customer_id=orders.customer_id +WHERE detail.customer_id IS NULL; +ALTER TABLE ll_order_detail + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_order_detail' AND index_name='idx_ll_detail_customer_order'), 'SELECT 1', + 'ALTER TABLE ll_order_detail ADD KEY idx_ll_detail_customer_order(customer_id,order_id,id)')); +PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt; + +-- 四期平台排车查询、智能预排与草稿锁;不开放编辑、发车、PDA 出库或派送。 +CREATE TABLE IF NOT EXISTS ll_dispatch_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', + driver_name VARCHAR(128) NULL COMMENT '司机姓名', driver_phone VARCHAR(64) NULL COMMENT '司机电话', + carrier_name VARCHAR(255) NULL COMMENT '承运商名称', route VARCHAR(255) NULL COMMENT '运输路线', + depart_at DATETIME(3) NULL COMMENT '发车时间', fba_appointment_no VARCHAR(64) NULL COMMENT 'FBA预约号', + fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', + vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT 'PDA累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因', + created_by VARCHAR(128) NULL COMMENT '创建人ID', 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_dispatch_order_no(customer_id,dispatch_no), + KEY idx_ll_dispatch_order_list(customer_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle(customer_id,vehicle_plate) +) ENGINE=InnoDB COMMENT='尾程派车单'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_item ( + id BIGINT NOT NULL COMMENT '主键ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + order_id BIGINT NULL COMMENT '订单ID', order_no VARCHAR(64) NULL COMMENT '订单号', + order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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), KEY idx_ll_dispatch_item_inbound(customer_id,inbound_code), + KEY idx_ll_dispatch_item_pallet(customer_id,plan_pallet_id,warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='派车货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', + plan_id BIGINT NULL COMMENT '入仓计划ID', plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空', + draft_container_id BIGINT NULL COMMENT '智能排车草稿装车托盘ID', draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识', + operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID', status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空', + released_at DATETIME(3) 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(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), + KEY idx_ll_dispatch_pallet_lock_source(customer_id,source_pallet_key,status) +) ENGINE=InnoDB COMMENT='派车托盘排他锁'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_draft ( + id BIGINT NOT NULL COMMENT '主键ID', draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识', + customer_id VARCHAR(128) NULL COMMENT '客户租户ID', operator_id VARCHAR(128) NULL COMMENT '草稿操作人ID', + plan_ids_json VARCHAR(4000) NULL COMMENT '所选入仓计划ID列表JSON', containers_json LONGTEXT NULL COMMENT '草稿装车布局JSON快照', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态', expires_at DATETIME(3) NULL COMMENT '草稿失效时间', + released_at DATETIME(3) 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(id), + UNIQUE KEY uk_ll_dispatch_draft_token(draft_token), KEY idx_ll_dispatch_draft_operator(operator_id,status) +) ENGINE=InnoDB COMMENT='智能排车草稿'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_draft_container ( + id BIGINT NOT NULL COMMENT '主键ID', draft_id BIGINT NOT NULL COMMENT '智能排车草稿ID', + draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识', sequence_no INT NOT NULL COMMENT '草稿车厢顺序号', + capacity INT NOT NULL COMMENT '草稿车厢托盘容量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), + KEY idx_ll_dispatch_draft_container_token(draft_token) +) ENGINE=InnoDB COMMENT='智能排车草稿装车托盘'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), + 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL COMMENT ''关联订单信息'' AFTER order_no')); +PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_container_id'), + 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_container_id BIGINT NULL COMMENT ''智能排车草稿装车托盘ID'' AFTER dispatch_order_id')); +PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_token'), + 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_token VARCHAR(64) NULL COMMENT ''智能排车草稿随机标识'' AFTER draft_container_id')); +PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='operator_id'), + 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN operator_id VARCHAR(128) NULL COMMENT ''草稿锁操作人ID'' AFTER draft_token')); +PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='expires_at'), + 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN expires_at DATETIME(3) NULL COMMENT ''草稿锁失效时间,正式派车锁为空'' AFTER locked_at')); +PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), + 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空''')); +PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt; + +-- 入仓库存按 SKU 冻结维度;旧环境只补结构,不由部署自动执行。 +CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( + id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', + line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', + warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), + KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), KEY idx_ll_inbound_carton_order(order_id,status) +) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), + 'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''', + 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; + +-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), + 'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); +PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id IS NULL OR l.customer_id=''; +ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id IS NULL OR v.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id IS NULL OR a.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); +PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt; +DROP PROCEDURE IF EXISTS assert_phase4_inbound_plan_customer_scope_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_inbound_plan_customer_scope_ready() +BEGIN + IF EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inbound plan child customer scope migration is incomplete'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_inbound_plan_customer_scope_ready(); +DROP PROCEDURE assert_phase4_inbound_plan_customer_scope_ready; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), + 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'), + 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); +PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) +WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; + +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓ID快照'' AFTER customer_name')); +PREPARE phase4_source_warehouse_id_stmt FROM @ddl; EXECUTE phase4_source_warehouse_id_stmt; DEALLOCATE PREPARE phase4_source_warehouse_id_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'), + 'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id')); +PREPARE phase4_source_warehouse_name_stmt FROM @ddl; EXECUTE phase4_source_warehouse_name_stmt; DEALLOCATE PREPARE phase4_source_warehouse_name_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='overseas_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_inbound_plan ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外实际收货共享物理仓ID,由PDA认领柜号时冻结'' AFTER container_no')); +PREPARE phase4_plan_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_plan_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_plan_receiving_warehouse_stmt; + +UPDATE ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id +SET o.source_warehouse_id=COALESCE(o.source_warehouse_id,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))), + o.source_warehouse_name=COALESCE(o.source_warehouse_name,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseName'))), + o.overseas_warehouse_id=NULL,o.overseas_warehouse_name=NULL +WHERE o.current_status<>'INBOUND_COMPLETED' + AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId')); + +DROP PROCEDURE IF EXISTS assert_pending_orders_do_not_use_source_warehouse; +DELIMITER $$ +CREATE PROCEDURE assert_pending_orders_do_not_use_source_warehouse() +BEGIN + IF EXISTS(SELECT 1 FROM ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id + WHERE o.current_status<>'INBOUND_COMPLETED' AND o.overseas_warehouse_id IS NOT NULL + AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='未完成尾程单仍将国内来源仓冒充海外实际收货仓'; + END IF; +END$$ +DELIMITER ; +CALL assert_pending_orders_do_not_use_source_warehouse(); +DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', + receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) 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 '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), + KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; + +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no')); +PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_name'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''仓库服务校验后的海外实际收货共享物理仓名称快照'' AFTER overseas_warehouse_id')); +PREPARE phase4_session_receiving_warehouse_name_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_name_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_name_stmt; + +DROP PROCEDURE IF EXISTS assert_active_receiving_session_has_warehouse; +DELIMITER $$ +CREATE PROCEDURE assert_active_receiving_session_has_warehouse() +BEGIN + IF EXISTS(SELECT 1 FROM ll_receiving_container_session WHERE active_container_key IS NOT NULL + AND (overseas_warehouse_id IS NULL OR overseas_warehouse_name IS NULL OR overseas_warehouse_name='')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在无法安全推断海外实际收货仓的未完成PDA柜会话'; + END IF; +END$$ +DELIMITER ; +CALL assert_active_receiving_session_has_warehouse(); +DROP PROCEDURE assert_active_receiving_session_has_warehouse; +ALTER TABLE ll_receiving_container_session + MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='sign_photos'), + 'ALTER TABLE ll_receiving_container_session MODIFY COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表''', + 'ALTER TABLE ll_receiving_container_session ADD COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表'' AFTER status')); +PREPARE phase4_receiving_sign_photos_stmt FROM @ddl; EXECUTE phase4_receiving_sign_photos_stmt; DEALLOCATE PREPARE phase4_receiving_sign_photos_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='photo_file_ids'), + 'ALTER TABLE ll_receiving_container_session DROP COLUMN photo_file_ids', 'SELECT 1')); +PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt; + +DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable; +UPDATE ll_receiving_container_session +SET active_operator_key=NULL,active_container_key=NULL +WHERE status='COMPLETED' AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL); +DELIMITER $$ +CREATE PROCEDURE assert_receiving_global_locks_migratable() +BEGIN + IF EXISTS (SELECT operator_key FROM ll_receiving_container_session + WHERE active_operator_key IS NOT NULL GROUP BY operator_key HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局账号锁迁移失败:同一账号存在多个未完成柜'; + END IF; + IF EXISTS (SELECT UPPER(TRIM(container_no)) FROM ll_receiving_container_session + WHERE active_container_key IS NOT NULL GROUP BY UPPER(TRIM(container_no)) HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局柜号锁迁移失败:同一柜存在多个未完成会话'; + END IF; +END$$ +DELIMITER ; +CALL assert_receiving_global_locks_migratable(); +DROP PROCEDURE assert_receiving_global_locks_migratable; +UPDATE ll_receiving_container_session +SET active_operator_key=CONCAT('OPERATOR:',operator_key), + active_container_key=CONCAT('CONTAINER:',UPPER(TRIM(container_no))) +WHERE active_operator_key IS NOT NULL OR active_container_key IS NOT NULL; +ALTER TABLE ll_receiving_container_session + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', + MODIFY COLUMN active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + MODIFY COLUMN active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键'; + +CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( + id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照', + received_packages INT NOT NULL COMMENT 'SKU实收箱数', 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 '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code), + KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id), + KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id) +) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( + id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; +DROP PROCEDURE IF EXISTS assert_receiving_operation_global_key_migratable; +DELIMITER $$ +CREATE PROCEDURE assert_receiving_operation_global_key_migratable() +BEGIN + IF EXISTS (SELECT operator_key,idempotency_key FROM ll_receiving_container_operation + GROUP BY operator_key,idempotency_key HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局幂等键迁移失败:同一操作账号存在重复幂等键'; + END IF; +END$$ +DELIMITER ; +CALL assert_receiving_operation_global_key_migratable(); +DROP PROCEDURE assert_receiving_operation_global_key_migratable; +SET @receiving_operation_global_index = ( + SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation' + AND index_name='uk_ll_receiving_operation' + GROUP BY index_name + HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index)= 'operator_key,idempotency_key' + ) ready_index +); +SET @receiving_operation_named_index = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation' + AND index_name='uk_ll_receiving_operation' +); +SET @ddl = IF(@receiving_operation_global_index=1, 'SELECT 1', + IF(@receiving_operation_named_index>0, + 'ALTER TABLE ll_receiving_container_operation DROP INDEX uk_ll_receiving_operation, ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)', + 'ALTER TABLE ll_receiving_container_operation ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)')); +PREPARE phase4_receiving_operation_index_stmt FROM @ddl; EXECUTE phase4_receiving_operation_index_stmt; DEALLOCATE PREPARE phase4_receiving_operation_index_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), + 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', + 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); +PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'), + 'SELECT 1', + 'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status')); +PREPARE phase4_sub_order_status_stmt FROM @ddl; EXECUTE phase4_sub_order_status_stmt; DEALLOCATE PREPARE phase4_sub_order_status_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='order_type' AND COALESCE(column_comment,'') LIKE '订单类型:%'), + 'SELECT 1', + 'ALTER TABLE ll_order MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT ''FIRST_LEG'' COMMENT ''订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发''')); +PREPARE phase4_order_type_comment_stmt FROM @ddl; EXECUTE phase4_order_type_comment_stmt; DEALLOCATE PREPARE phase4_order_type_comment_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='current_status' AND COALESCE(column_comment,'') LIKE '当前业务状态:%'), + 'SELECT 1', + 'ALTER TABLE ll_order MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT ''PENDING_ASSIGNMENT'' COMMENT ''当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成''')); +PREPARE phase4_order_status_comment_stmt FROM @ddl; EXECUTE phase4_order_status_comment_stmt; DEALLOCATE PREPARE phase4_order_status_comment_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='uk_ll_inbound_plan_container'), + 'ALTER TABLE ll_inbound_plan DROP INDEX uk_ll_inbound_plan_container', 'SELECT 1')); +PREPARE phase4_inbound_stmt FROM @ddl; EXECUTE phase4_inbound_stmt; DEALLOCATE PREPARE phase4_inbound_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='idx_ll_inbound_plan_container'), + 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD KEY idx_ll_inbound_plan_container (customer_id,container_no)')); +PREPARE phase4_inbound_stmt FROM @ddl; EXECUTE phase4_inbound_stmt; DEALLOCATE PREPARE phase4_inbound_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''', + 'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度'' AFTER inbound_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_en'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), + 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +ALTER TABLE ll_inventory_balance COMMENT='客户入仓单与海外目的仓维度实时库存余额'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at)')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), + 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照'' AFTER inbound_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_zh'), + 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_en'), + 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'), + 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation', 'SELECT 1')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'), + 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; + +-- 海运节点达到可提柜后才允许海外 PDA 认领;装柜完成只建立等待门禁快照。 +CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( + container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', + container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', + standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', + node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号', + observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', + actual_at DATETIME(3) NULL COMMENT '节点实际发生时间', + location_code VARCHAR(128) NULL COMMENT '节点地点编码', + location_name VARCHAR(255) NULL COMMENT '节点地点名称', + provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码', + provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID', + provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码', + source_reference VARCHAR(255) NULL COMMENT '供应商来源引用', + raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256', + aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本', + handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本', + last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID', + payload_hash VARCHAR(71) 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(container_booking_id), + KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status), + KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) +) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; + +INSERT IGNORE INTO ll_container_shipping_gate ( + container_booking_id,customer_id,ocean_booking_id,container_no,status, + node_sequence,aggregate_version,handover_version,created_at,updated_at +) +SELECT source_container_booking_id,customer_id,'',container_no,'AWAITING_PICKUP_READY', + 0,COALESCE(MAX(source_loading_plan_version),0),COALESCE(MAX(source_handover_version),1),NOW(3),NOW(3) +FROM ll_order +WHERE source_container_booking_id IS NOT NULL AND source_container_booking_id <> '' + AND container_no IS NOT NULL AND container_no <> '' +GROUP BY source_container_booking_id,customer_id,container_no; + +CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( + id BIGINT NOT NULL COMMENT 'PDA作业历史ID', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', + operation_type VARCHAR(64) NOT NULL COMMENT '作业类型', + business_key VARCHAR(255) NOT NULL COMMENT '作业业务键', + status VARCHAR(32) NOT NULL COMMENT '作业结果状态', + idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键', + request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希', + summary VARCHAR(1000) NULL COMMENT '作业结果摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at), + KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key) +) ENGINE=InnoDB COMMENT='海外PDA真实作业历史'; + +CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation ( + id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键', + operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执', + last_error VARCHAR(2000) 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(id), UNIQUE KEY uk_ll_pda_pallet_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task ( + id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照', + status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数', + counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数', + created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识', + submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no), + UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key), + KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item ( + id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识', + balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照', + product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照', + book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数', + difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态', + position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识', + version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action ( + id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希', + status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( + id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID', + source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID', + target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数', + position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数', + pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因', + idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希', + status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_inventory_move_no(move_no), + UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key), + KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_task ( + id BIGINT NOT NULL COMMENT '换标PDA任务ID', application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID', + status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED', + operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照', + reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照', + expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数', + skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数', + completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注', + review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', review_remark VARCHAR(1000) NULL COMMENT '最近复核备注', + claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', submitted_at DATETIME(3) NULL COMMENT '提交复核时间', + reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', version INT 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_relabel_pda_task_application (customer_id,application_id), + UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at), + KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact ( + id BIGINT NOT NULL COMMENT '逐件换标事实ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', item_id BIGINT NOT NULL COMMENT '换标申请明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标', + new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', + exception_code VARCHAR(32) NULL COMMENT '异常编码', reason VARCHAR(1000) NULL COMMENT '跳过或异常原因', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id), + KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id) +) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation ( + id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识', + action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key), + KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_history ( + id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + action VARCHAR(32) NOT NULL COMMENT '现场动作类型', from_status VARCHAR(32) NULL COMMENT '动作前任务状态', + to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', detail_json LONGTEXT NULL COMMENT '动作详情JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id), + KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'), + 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash')); +PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'), + 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); +PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; + +-- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。 +-- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。 +USE baiyue_lastlag_warehouse; +-- 仓库主档唯一权威已收口至国内 baiyue_warehouse;海外库只保留 warehouse_id 引用。 +-- 当前开发阶段不兼容旧海外主档数据,按依赖顺序移除重复表。 +DROP TABLE IF EXISTS wh_service_capability; +DROP TABLE IF EXISTS wh_warehouse_address; +DROP TABLE IF EXISTS wh_warehouse; + +CREATE TABLE IF NOT EXISTS wh_pallet_operation ( + id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', + request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', + before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', + before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', + operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', + reason VARCHAR(500) 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 (id), UNIQUE KEY uk_wh_pallet_operation_key (operation_key), + KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='fba_warehouse_code'), + 'SELECT 1', + 'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark')); +PREPARE phase4_warehouse_pallet_stmt FROM @ddl; +EXECUTE phase4_warehouse_pallet_stmt; +DEALLOCATE PREPARE phase4_warehouse_pallet_stmt; + +SET @warehouse_pallet_fba_ready = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet' + AND column_name='fba_warehouse_code' AND column_type='varchar(128)'); +DROP PROCEDURE IF EXISTS assert_phase4_warehouse_pallet_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_warehouse_pallet_ready() +BEGIN + IF @warehouse_pallet_fba_ready <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='wh_pallet.fba_warehouse_code migration is incomplete'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_warehouse_pallet_ready(); +DROP PROCEDURE assert_phase4_warehouse_pallet_ready; + +USE baiyue_lastleg; +DROP PROCEDURE IF EXISTS assert_phase4_pda_operations_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_pda_operations_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_lastleg' AND table_name IN ( + 'll_pda_pallet_operation','ll_pda_stocktake_task','ll_pda_stocktake_item', + 'll_pda_stocktake_action','ll_pda_inventory_move','ll_relabel_pda_task', + 'll_relabel_pda_unit_fact','ll_relabel_pda_operation','ll_relabel_pda_history')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA盘点、移库、托盘或换标表未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA实物托盘操作表未部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_pda_operations_ready(); +DROP PROCEDURE assert_phase4_pda_operations_ready; + +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', + status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), KEY idx_security_inbox_type_time (event_type,received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; +CREATE TABLE IF NOT EXISTS ll_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', + last_event_id VARCHAR(64) 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 COMMENT='海外IAM安全状态与权限版本投影'; + +-- 与国内固定 auth_account.id=1000001、tenant_id=0 的 platform_admin 一致;真实事件从版本1覆盖。 +INSERT IGNORE INTO ll_security_projection ( + aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version, + payload_hash,last_event_id,changed_at,updated_at +) VALUES + ('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256), + 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), + ('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), + 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); + +DROP PROCEDURE IF EXISTS assert_lastleg_security_projection_ready; +DELIMITER $$ +CREATE PROCEDURE assert_lastleg_security_projection_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' + AND table_name IN ('ll_security_event_inbox','ll_security_projection')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_lastleg_security_projection_ready(); +DROP PROCEDURE assert_lastleg_security_projection_ready; +SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; diff --git a/docs/Order-Firstleg在线依赖解环设计.md b/docs/Order-Firstleg在线依赖解环设计.md new file mode 100644 index 000000000..d8828c29e --- /dev/null +++ b/docs/Order-Firstleg在线依赖解环设计.md @@ -0,0 +1,60 @@ +# Order↔Firstleg 在线依赖解环设计 + +## 结论 + +当前不能在不改变已确认产品成功时点的前提下直接实施解环,业务代码保持不变。在线图仍包含 +`Order→Firstleg` 与 `Firstleg→Order`,所以非平凡强连通分量仍是 `Firstleg↔Order`。 + +建议的最小退出方向是 **退出全部 `Firstleg→Order` REQUEST 边**。该方向只有 +`OrderCargoClient` 和 `OrderFulfillmentSplitClient` 两个客户端;Order 继续拥有订单、货物、父子订单和 +拆分台账,Firstleg 继续拥有入库、测量、分柜装柜、PDA 拣货和库位结算。完成后只保留 +`Order→Firstleg` 在线查询/门禁边以及双方后台边,在线 SCC 才会实际消失。 + +## 当前可执行调用 + +| 方向/客户端 | Controller→Service→Client→被调用入口 | 时点与调用者期望 | 失败传播 | 幂等性 | +|---|---|---|---|---| +| Order→Firstleg `FirstlegPricingClient` | `MerchantFirstLegOrderController`、`PlatformFirstLegOrderController` 的创建/编辑/提交 → `OrderApplicationService` → `OrderPricingChannelGuard` → `/internal/firstleg/v1/pricing/channels/validate` | 保存或提交前实时确认渠道有效;提交还必须取得入仓码前缀 | 业务错误原码上传;连接、空响应或不完整数据映射依赖不可用/冲突,订单不提交 | 纯读取 `SAFE_READ` | +| Order→Firstleg `FirstlegUnifiedOrderClient` | `PlatformUnifiedOrderController`、`MerchantUnifiedOrderController` 列表/详情 → `UnifiedOrderQueryService` → Firstleg unified query/summaries/detail | 一个响应内组合 Order 主数据与 Firstleg 实时履约、测量、装柜和时间线 | 远端异常、失败码、空数据均失败关闭,不返回伪造履约摘要 | 纯读取 `SAFE_READ` | +| Order→Firstleg `FirstlegInboundClient` | `MerchantFirstLegOrderController.page` → `MerchantInboundOrderListService` → references/prepareBatch;已提交订单编辑提交后 → `OrderApplicationService.prepareDraftInbound` → prepare;审核 Outbox → `OrderInboundOutboxPublisher` → create | 列表需要入库引用;草稿/待审编辑尝试同步预入库;审核成功已与 Firstleg 建单解耦 | references 失败关闭;prepare/prepareBatch 当前捕获异常并允许列表缺少入库引用,是现有失败传播缺口;审核 Outbox 持久化重试 | `tenantId+orderId` 业务键;Firstleg 对订单唯一且入仓码一致时幂等 | +| Firstleg→Order `OrderCargoClient` | 箱号 Controller/Order 审核后台建单 → `InboundCartonLabelService`;测量 Controller/OpenAPI → `InboundMeasurementService`/`InboundMeasurementOpenApiService`;智能装柜 Controller → `SmartLoadingService` → Order cargo/snapshot endpoints | 首次箱号、测量明细、申报回填和装柜清单均要求当前订单货物事实 | 服务不可用、空数据、归属不一致均阻断当前操作;没有默认货物或空列表成功 | 纯读取 `SAFE_READ`;同时存在 REQUEST 和审核 Outbox 回调产生的 BACKGROUND 上下文 | +| Firstleg→Order `OrderFulfillmentSplitClient` | `PlatformLoadingPdaController.complete` → `LoadingPickService.complete @Transactional` → `PdaPickSettlementService.settle` → `/internal/order/v1/fulfillment-splits` → `OrderFulfillmentSplitService.split @Transactional` | PDA 完成请求必须先取得 Order 真实余量子单 ID/单号,再在同一 Firstleg 事务写分片、测量、库位、审计并推进装柜完成 | Order 不可用或响应数量/柜次/父子关系不一致时整个 Firstleg 完成事务回滚;不得返回完成成功 | Firstleg 使用 `idempotencyKey=pda-task-{taskId}`、`operationId=PDA-SPLIT-{taskId}-{inboundId}`;Order 保存请求哈希并以唯一键重放 | + +## 事实所有权 + +- Order:订单交易状态、订单货物及允许编辑的字段、父子订单 ID/单号、履约拆分台账和订单版本。 +- Firstleg:入库单、测量事实、分柜/装柜计划、PDA 拣货数量、库位结算、装柜证据和 `LOADING→LOADED` 门禁。 +- 允许复制的仅是带来源版本和哈希的 Order 快照;Firstleg 不得成为订单货物或父子订单的第二写入方。 +- 统一订单查询是组合读模型,不把 Firstleg 履约状态写回 Order 交易状态。 + +## 推荐退出方案 + +### 1. 先退出货物在线读取 + +复用 Order 已有 Outbox 和 Firstleg 已有订单入库接收入口,将完整货物快照、订单版本和规范化哈希随草稿准备、待审编辑和审核事件可靠投递;Firstleg 以 +`tenantId+orderId+orderVersion` 单调 Inbox/快照落库。箱号、测量和智能装柜只读本地快照,缺失、版本冲突或哈希冲突均失败关闭。 + +不能只在审核时复制一次:当前契约允许 `LOADED` 前按权限和状态修改业务资料。必须先冻结货物在哪个节点停止修改,或要求每次允许的 Order 编辑都可靠发布版本事件,并明确 Firstleg 在投影落后期间阻断哪些操作。 + +### 2. 再将履约拆分改为可靠后台命令 + +Firstleg 在本地持久化稳定 operation/idempotency key、数量行、柜次和请求哈希;后台 Relay 调用现有 Order 幂等拆分接口并保存完整响应。Order 现有拆分台账和唯一键可承担 Inbox/业务幂等,不需要新服务或 MQ。 + +Firstleg 只有在后台结果为 `READY` 且父子关系、数量和柜次全部校验通过后,才能消费余量子订单 ID并执行本地结算。失败进入可诊断重试/DEAD,禁止默认成功。远端成功、本地未完成时只允许向前恢复,不得自动删除 Order 子订单。 + +这一方案会引入技术态 `PENDING/READY/FAILED`,但不能擅自增加产品主状态。若在 PDA 首次点击完成时只创建任务并返回待处理,成功时点发生变化;若提前创建 Order 子单,则子单可能在装柜硬门禁完成前可见,同样改变现有语义。 + +## 必须由产品冻结的决策 + +1. **货物冻结点**:Order 货物在审核通过、首次生成箱号、确认测量还是确认预装后不可再改;若仍允许修改,哪些 Firstleg 操作必须等待最新 `orderVersion` 投影。 +2. **PDA 完成成功时点**:必须继续在一次请求中同时完成 Order 子单与 Firstleg 结算,还是允许首次请求返回“处理中”,后台完成后才成为 `LOADED`。同时确认后台已创建 Order 子单但 Firstleg 最终失败时采用持续向前修复,不提供在线冲正。 + +上述任一决策未冻结前,不实施业务解环。 + +## 最小实施与验证边界(决策冻结后) + +- Order:扩充版本化货物快照 Outbox;保留 `OrderFulfillmentSplitService` 事实所有权和现有幂等唯一键。 +- Firstleg:货物快照 Inbox/表、拆分命令 Outbox/Relay/诊断查询;替换两个在线 Client 的业务调用点。 +- SQL:仅更新当前模块基线与统一增量,不引入 Flyway/Liquibase,不执行 SQL。 +- 合同:证明生产请求路径不再引用两个客户端、`Firstleg→Order` 只剩 BACKGROUND,REQUEST SCC 中不再出现两者同一分量。 +- 测试:Order/Firstleg 全量直接测试、重复/异哈希事件、租约恢复、远端成功本地失败重放、DEAD 人工重试、PDA 未 READY 失败关闭,以及同步依赖合同。 diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md new file mode 100644 index 000000000..f7cba31aa --- /dev/null +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -0,0 +1,149 @@ +# 同步调用依赖与失败传播治理基线 + +## 目的与口径 + +本基线覆盖当前官方 22 个 Maven 模块、15 个可执行服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。海外安全 Inbox/投影属于 Lastleg 内部子域,不再作为独立服务节点。基线用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。 + +“存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分: + +- `REQUEST`:用户或设备请求线程内,失败直接影响当前业务结果; +- `BACKGROUND`:Outbox、Inbox、定时器或导出 Worker 中的同步传输,业务事务已与传输解耦; +- `INFRASTRUCTURE`:审计、网关会话校验等基础设施调用; +- `EXTERNAL`:Integration 对第三方供应商的调用。 + +逐客户端可执行清单位于 `scripts/synchronous-dependency-baseline.mjs`,离线合同为 `scripts/synchronous-dependency-contract.test.mjs`。清单逐项冻结 44 个 Feign 与 16 个原始 HTTP 客户端的执行上下文、`READ/WRITE/READ_WRITE`、幂等类型和说明,并从同一事实源输出 REQUEST、BACKGROUND、INFRASTRUCTURE、EXTERNAL 与全图。新增客户端、目标服务、在线边、强连通分量或超时配置时,必须先更新基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。 + +## 当前服务边 + +### OpenFeign + +| 调用方 | 被调用方 | 客户端数 | 主要上下文 | +|---|---|---:|---| +| Auth | IAM | 2 | 登录授权、注册,`REQUEST` | +| IAM | Auth | 1 | 账号开通/管理,`REQUEST` | +| IAM | Firstleg | 1 | 客户尺寸默认规则可靠任务投递,`BACKGROUND` | +| IAM | Warehouse | 1 | 海外仓储费协作,`REQUEST` | +| IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` | +| Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` | +| Order | IAM / File / Warehouse | 各 1 | 资格、图片、仓库守卫,`REQUEST` | +| Firstleg | Order | 2 | 货物查询同时存在 `REQUEST/BACKGROUND`;PDA 履约分拆为 `REQUEST` | +| Firstleg | IAM / Finance / Integration / Event Bridge / Print | 各 1 | IAM、Integration、Print 为 `REQUEST`;Finance 与 Event Bridge 为 `BACKGROUND` 中继 | +| Firstleg | File | 7 | 测量、装柜、凭证、报关、拖车等,`REQUEST` | +| Firstleg | Warehouse | 4 | 主数据、供应商、库位/托盘,`REQUEST` | +| Finance | Firstleg | 2 | 费用来源/成本快照,事件消费与平台操作混合 | +| Finance | File / IAM / Warehouse | 各 1 | 凭证、客户、供应商校验,`REQUEST` | +| File | IAM | 2 | 资质登记为 `REQUEST`;导出 Provider 为 `BACKGROUND` | +| File | Order / Firstleg / Finance / Warehouse | 各 1 | 导出 Provider,`BACKGROUND` | +| File | Integration | 1 | OCR 在上传处理请求线程执行,`REQUEST` | +| Lastleg | Event Bridge | 1 | 跨区事件 Outbox 中继,`BACKGROUND` | +| Lastleg | 国内 Warehouse | 1 | 合作仓报价快照,`REQUEST` | + +合计 44 个 Feign 客户端。公共默认连接/读取超时为 3 秒/5 秒;File 的五个导出 Provider 客户端读取超时单独为 15 秒。 + +### 原始 HTTP + +| 调用方 | 被调用方 | 实现 | 上下文与当前策略 | +|---|---|---|---| +| Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 | +| Gateway Overseas | Lastleg Security 子域 | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;Lastleg 内部安全投影实时校验,异常失败关闭,不进入在线业务图 | +| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、5 秒读取超时,配置可覆盖,不自动重试 | +| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、30 秒读取超时,配置可覆盖;校验响应 code 且失败关闭,不自动重试 | +| Event Bridge | Firstleg / Lastleg(业务事件与安全投影两个入口) | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Lastleg Security 投影不进入在线业务图 | +| 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 | +| Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 | + +#### Lastleg 四个有界 RestClient + +四个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。 + +| 客户端 | 构建与调用路径 | 写操作幂等性 | 默认超时 | +|---|---|---|---| +| `HttpUnloadPhotoFileGateway` | `ReceivingContainerController → ReceivingContainerService → UnloadPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 每次写文件和元数据,没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | +| `HttpRelabelPhotoFileGateway` | `RelabelPdaController/Service → RelabelPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | +| `HttpWarehouseResourceGateway` | `InboundPdaWorkflowService`、PDA 仓内查询/盘点及 Dropship → Warehouse Resource/Pallet 内部接口 | 查询可重复;`reserve` 使用 `reservationKey`,`ensureDropship` 使用业务绑定,release/scrap 由服务端状态收敛;尽管服务端具备业务幂等,客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | +| `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | + +## 分上下文依赖图 + +### 在线 REQUEST 图 + +在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和 Gateway→Lastleg Security 基础设施校验均不计入在线深度。 + +当前在线强连通分量为: + +`Auth ↔ IAM`;`Firstleg ↔ Order` + +`IAM → Firstleg` 默认尺寸初始化已退出请求线程,合同现冻结 21 条在线服务边;新增在线边或新增强连通分量直接失败。File 不再属于在线强连通分量;Finance、Lastleg 只指向上述分量或叶子服务,不属于在线环;Gateway Overseas→Lastleg Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 + +### 后台 BACKGROUND 图 + +后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为: + +`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg ↔ Order` + +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、该入库消费者为生成箱号而回读 Order 货物的后台回调、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。 + +### 全图 + +全上下文合并后,非平凡强连通分量仍为 8 个服务: + +`Auth ↔ IAM ↔ Order ↔ Firstleg ↔ Finance ↔ File ↔ Event Bridge ↔ Lastleg` + +全图只用于部署影响和故障域观察,不能再用于推导在线调用“无限深度”。 + +### 用户入口在线最长简单路径 + +默认在线预算为最多 2 个下游服务跳数。合同对每个已知用户入口枚举全部最长简单路径;下表列出的具体路径均超过预算: + +| 用户入口 | 最长跳数 | 全部长路径 | +|---|---:|---| +| 订单 API | 4 | `Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse` | +| 头程 API/PDA | 4 | `Firstleg → Order → File → IAM → Auth`;`Firstleg → Order → File → IAM → Warehouse` | +| 财务 API | 5 | `Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse` | +| 海外尾程 API/PDA | 3 | `Lastleg → File → IAM → Auth`;`Lastleg → File → IAM → Warehouse` | + +这些是静态服务边可形成的最长简单路径,不表示每个具体 Controller 都会走完整链;后续入口级调用点治理必须继续以实际 Service 调用证据缩小路径,不能用静态可能性宣称线上已经发生。 + +### 已完成的第一个 REQUEST 边退出 + +`IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化已经从 REQUEST WRITE 改为可靠后台编排。IAM 与客户主档同事务持久化稳定 `initializationKey` 的任务,显式维护 `PENDING/READY/FAILED`,由租约调度重试并提供诊断查询和人工重试;审批通过与下单资格在未 READY 时失败关闭。此边现在仅属于 BACKGROUND,在线五服务强连通分量实际拆为 Auth↔IAM 与 Order↔Firstleg 两个子环,未触碰财务、Event Bridge 或四期业务状态机。 + +### Order↔Firstleg 第二阶段结论 + +具体入口、所有权与可执行退出方案见 `docs/Order-Firstleg在线依赖解环设计.md`。当前 `COMPLETE_LOADING` 在 Firstleg 本地事务中同步取得 Order 余量子单后才完成结算,且 Order 货物在 `LOADED` 前仍允许修改;成功时点和货物冻结点尚未冻结,因此本阶段不改业务代码。REQUEST SCC 仍为 `Firstleg↔Order`,合同明确阻止仅改变分类却未退出生产调用的伪解环。 + +## 退出顺序 + +1. **冻结与分层。** 先用清单合同阻止未评审新边;为每个客户端补齐 `REQUEST/BACKGROUND/INFRASTRUCTURE/EXTERNAL`、读写语义、幂等键和失败映射。这个阶段不改变业务状态。 +2. **统一失败传播与时间预算。** 为 Feign 和原始 HTTP 明确连接/读取/总超时;默认禁用自动重试,只有具备幂等键且由业务明确拥有退避状态的后台操作可以重试;禁止空数据、默认成功或仅日志告警替代远端失败。 +3. **先退出伪在线环。** File 导出 Provider、Firstleg/Lastleg Event Bridge、Firstleg→Finance 费用投递继续作为后台链路,但必须从在线深度图剥离,并禁止 Worker 消费时无界同步回调。 +4. **退出 `Order ↔ Firstleg` 在线双向命令。** 保留一个方向的权威查询,将反向状态推进改为带版本、幂等和审计的 Outbox/Inbox;不复制 Order 交易状态或 Firstleg 履约状态。 +5. **退出 `Auth ↔ IAM` 在线双向写。** Auth 保持账号与 Token 唯一所有者,IAM 保持客户/RBAC 所有者;注册和账号开通使用可恢复编排,登录授权快照失败关闭,不引入第二套账号事实。 +6. **收敛 `IAM ↔ Firstleg`。** 客户维度规则和客户快照确定单一事实方向;允许短期只读查询,禁止两个服务互相修正对方数据。 +7. **在财务总控确认后退出 `Firstleg ↔ Finance` 回调。** 优先让版本化事件携带 Finance 校验所需的不可变快照或使用单向快照读取,保持 Finance 状态机和 Firstleg 费用事实边界不变。 +8. **收敛 File 相关环。** File 只拥有文件对象与导出任务;业务服务拥有导出数据和业务归属。凭证/照片校验失败关闭,导出 Worker 使用受限批次、超时和显式失败状态。 + +## 错误、重试与熔断基线 + +- 远端 HTTP 非 2xx、响应体 `code != "200"`、空响应和 DTO 不完整是四类不同失败,均不得变成成功或空列表。 +- 在线查询/命令默认不自动重试。若启用重试,必须同时证明方法幂等、请求具有稳定幂等键、最大尝试次数和总时间预算可控。 +- Outbox/Inbox 重试由持久化状态拥有,Feign/JDK 客户端不得再叠加隐式重试造成次数乘法。 +- 熔断打开时返回统一“依赖不可用”错误并保留 request/trace 关联信息,不配置业务成功型 fallback。 +- 404 只有在契约明确“资源不存在”时可映射为领域未找到;连接失败、超时、5xx、空响应必须映射为依赖不可用。 +- Common Audit 当前“只记录告警后返回”列为已知例外,不能据此推广静默降级;其可靠性与业务阻断口径需和审计/总控共同冻结。 + +## 直接受影响验证 + +| 变更范围 | 最低验证 | +|---|---| +| 图、客户端或超时配置 | `node --test scripts/synchronous-dependency-contract.test.mjs` | +| 单个 Feign/HTTP 签名或错误映射 | `mvn -pl <调用方>,<被调用方> -am test-compile`,并运行双方直接相关测试 | +| Auth/IAM | `mvn -pl auth,iam -am test` | +| Order/Firstleg | `mvn -pl order,firstleg -am test` | +| IAM/Firstleg | `mvn -pl iam,firstleg -am test` | +| Firstleg/Finance | `mvn -pl firstleg,finance -am test`;业务口径先由财务总控确认 | +| Firstleg/File 或 File/Finance | `mvn -pl firstleg,file,finance -am test`,按实际消费者缩小模块集 | +| Lastleg 原始 HTTP | `mvn -pl lastleg-service,warehouse-overseas-service,file -am test`;不改变四期状态机 | +| 全部治理阶段收口 | `mvn -DskipTests validate` 后执行 `mvn test` | + +以上均为本地离线验证;未经明确授权不启动 Java/Nacos/数据库,不执行 SQL、部署或测试环境 HTTP 验收。 diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 91a3f12ad..f16f95e37 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程业务后端仓库,采用 Maven 多模块单仓结构。仓库内包含 7 个公共模块和 11 个可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -24,7 +24,7 @@ - SpringDoc OpenAPI / Swagger - GitLab、Jenkins、Docker Compose -本仓库只保存头程代码。尾程业务位于独立 Git 仓库,不允许把尾程源码、数据库或配置重新放回本仓库。 +国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。 ## 2. 根目录结构 @@ -39,11 +39,15 @@ baiyue-firstleg/ ├─ common-event-contract/ # 国内外跨区域事件统一契约 │ ├─ gateway/ # 统一 API 网关 +├─ gateway-overseas/ # 海外区域 API 网关 ├─ auth/ # 统一登录与账号凭据 ├─ iam/ # 租户、成员、角色、权限、企业审核 ├─ order/ # 头程订单中心 ├─ firstleg/ # 履约任务、二期入库、测量与计费 ├─ warehouse/ # 仓库主数据、托盘与库位资源 +├─ finance/ # 财务账期、账单、收付款与核销 +├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 +├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 ├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 @@ -86,6 +90,7 @@ 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 计费、费用发布和完成入库编排;不是订单主数据服务 | @@ -94,10 +99,12 @@ baiyue-firstleg/ | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | | `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | -| `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 | +| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、海外本土业务及 IAM 安全事件投影/实时校验;不含海外出库和派送 | +| `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 是外部基础设施,不属于上述 11 个业务服务,也不应打包进业务镜像。 +MySQL、Redis、Nacos 是外部基础设施,不属于上述业务服务,也不应打包进业务镜像。 ## 5. 各服务文件夹说明 @@ -155,6 +162,8 @@ Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三 Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。 +头程装柜完成事件以实际集装箱为顶层批次,`orders[]` 中每项是一张实际发运的来源入仓单;一个柜可包含多张入仓单。每个货物明细携带来源明细 ID、实际装运箱数和完整的国内箱唛池。国内不要求在装柜时逐箱扫描冻结,完整箱唛池可以大于本柜实际装运箱数;海外按实际首次扫描的箱唛确认归属和实收,未扫描箱唛保留给后续子订单。海外将同柜各来源入仓单分别生成入库计划,但以同一集装箱号关联,并且只允许同一目的仓的箱子合托。 + ## 6. 配置、部署和辅助目录 ### `config/` @@ -165,7 +174,7 @@ config/nacos/ └─ cn/ # 国内服务器配置模板 ``` -每套目录包含公共配置 `baiyue-common.yml` 和 11 个服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 +`local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 ### `deploy/` @@ -174,7 +183,8 @@ deploy/ ├─ docker/Dockerfile # 通用 Java 17 镜像 ├─ env/.env.test.example # 环境变量示例 ├─ scripts/deploy-services.sh # Jenkins 增量部署脚本 -├─ docker-compose.yml # 11 个业务服务编排 +├─ docker-compose.yml # 国内区域服务编排 +├─ docker-compose.overseas.yml # 海外区域服务编排 ├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施 ├─ sql/ # 测试/存量环境统一迁移入口与说明 └─ service-map.yml # 模块、JAR、端口映射 @@ -271,6 +281,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会自动重建全部 11 个服务。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 -- 头程与尾程保持两个 Git 仓库、两套数据库和两套运行环境。 +- 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 59c29eeec..99d0553fb 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -2,33 +2,37 @@ ## 职责边界 -- 负责跨区域事件接收、内部令牌保护、契约校验和 Inbox 幂等持久化。 -- 事件模型来自 `common-event-contract/`;本服务不共享国内/国外业务数据库,也不承载头程或尾程领域逻辑。 -- 真实跨区发送、重试调度和死信补投仍需按需求继续完善,不把当前接收骨架描述为完整消息平台。 +- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。 +- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。 +- 事件结构和 Payload 哈希以 `common-event-contract` 为准。 ## 快速索引 | 功能 | 入口 | |---|---| -| 应用入口 | `src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java` | -| 事件接收与状态 API | `controller/EventBridgeController.java`(`/internal/event-bridge`) | -| 接收结果与状态 DTO | `dto/EventReceiveResult.java`、`BridgeStatus.java` | -| 校验与 Inbox 编排 | `service/EventInboxService.java` | -| Inbox 抽象与 MyBatis 实现 | `service/InboxStore.java`、`MybatisInboxStore.java` | -| Inbox 数据模型 | `domain/BridgeInbox.java`、`mapper/BridgeInboxMapper.java` | +| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | +| Inbox 接收与幂等 | `service/EventInboxService.java` | +| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload | +| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | +| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | +| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | +| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | +| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` | +| 事件 Trace 与原始 HTTP 传播 | `trace/EventBridgeTraceScope.java`、`EventBridgeHttpTracePropagation.java`;契约与已知缺口 `docs/event-trace-propagation.md` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | -| 国内装柜完成生产链 | `../firstleg/service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`client/EventBridgeClient.java`;Payload 为 `../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java` | -| 表结构 | `src/main/resources/db/event-bridge.sql` | -| 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` | -| 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 | +| 数据库 | `src/main/resources/db/event-bridge.sql` | +| 测试 | `src/test/java/com/baiyue/eventbridge/` | + +## 可靠投递规则 + +- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。 +- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。 +- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 +- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 +- 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。 +- 投递线程只从 Inbox 已冻结 `trace_id` 建立 MDC;合法 32 位 trace ID 或规范 UUID 可贯通,非法历史值安全重建。每次 JDK HttpClient 投递生成 child span,并覆盖三类标准 Trace 头;不得把 correlationId、eventId、customerId、聚合 ID、JWT 或内部令牌写入 Trace 字段。 ## 修改与验证 -- 先使用 `common-event-contract` 校验事件 ID、区域、版本、业务键、时间与 Payload 哈希,再执行幂等入库。 -- 重复事件应返回稳定结果且不重复产生副作用;数据库唯一约束与应用幂等逻辑要同步。 -- 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。 -- Event Bridge 内部令牌失败和接收基础设施不可用属于统一 HTTP 200 业务契约的明确例外,保留真实 401/503;已经通过 Controller 进入公共异常处理的普通业务失败才使用 HTTP 200 + body code。 -- 本服务只扫描 `com.baiyue.eventbridge`,统一异常和 Correlation 能力依赖 `common-web/CommonWebAutoConfiguration` 自动装配;调整公共 Web 配置后追加本模块上下文测试。 -- 契约变化按 `../common-event-contract/AGENTS.md` 直接同步当前模型、国内生产者和国外消费者,不保留旧事件兼容分支。 -- Event Bridge 只负责事件接收、校验和 Inbox 幂等,不负责用户登录或 JWT 转发;MQ 投递链路的服务身份与用户 Token 分离。 -- 从父目录运行 `mvn -pl event-bridge-service -am test`。 +- 定向验证:`mvn -pl event-bridge-service -am test`。 +- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。 diff --git a/event-bridge-service/docs/development-observability-slo.md b/event-bridge-service/docs/development-observability-slo.md new file mode 100644 index 000000000..1c05c13ce --- /dev/null +++ b/event-bridge-service/docs/development-observability-slo.md @@ -0,0 +1,67 @@ +# Event Bridge 开发阶段可观测性与 SLO 合同 + +## 状态与边界 + +本文冻结代码侧指标、SLI/SLO 和告警表达式模板。它不表示任何环境已经部署、接入或启用了 +Prometheus、Alertmanager 或 Grafana,也不授权部署、访问环境或修改环境配置。 + +指标标签只允许 `target_region`、`event_type`、`result`: + +- `target_region` 仅为 `CN`、`OVERSEAS`、`OTHER`。 +- `event_type` 仅为 `EventTypes` 已登记类型或 `OTHER`。 +- `result` 仅为代码中冻结的接收或传输结果枚举。 +- 严禁把 `eventId`、`customerId`、聚合 ID、错误消息、请求 URL 或操作人写入指标标签。 + +## 指标合同 + +| Prometheus 指标 | 含义 | +|---|---| +| `baiyue_event_bridge_receive_total` | 接收结果;`result=RECEIVED|DUPLICATE|CONFLICT` | +| `baiyue_event_bridge_transport_total` | 传输结果;`result=DELIVERED|RETRY|DEAD|ACKNOWLEDGED_LEASE_LOST|FAILED_LEASE_LOST|MANUAL_REPLAY` | +| `baiyue_event_bridge_delivery_latency_seconds_bucket` | 从 Event Bridge `received_at` 到下游首次成功确认;固定包含 60 秒和 900 秒 SLO 桶 | +| `baiyue_event_bridge_inbox_backlog` | 当前 `RECEIVED`、`RETRY`、`PROCESSING` 数量 | +| `baiyue_event_bridge_inbox_oldest_age_seconds` | 当前最老待处理事件自 `received_at` 起的年龄 | +| `baiyue_event_bridge_inbox_dead` | 当前 `DEAD` 数量 | + +`DELIVERED` 与 `ACKNOWLEDGED_LEASE_LOST` 都表示下游已经确认,因此均记录确认延迟;后者仍单独计数, +不得冒充本地 Inbox 已成功更新。`RETRY`、`DEAD`、`FAILED_LEASE_LOST` 不记录成功延迟。 + +Inbox Gauge 由服务每 30 秒执行一次受控聚合查询并缓存;Prometheus scrape 只读取 Micrometer Gauge, +不得触发数据库查询。查询仅处理 `RECEIVED`、`RETRY`、`PROCESSING`、`DEAD`,状态过滤以现有 +`idx_bridge_inbox_consume(status, next_retry_at, lease_until)` 为索引依据;本合同不新增或修改 SQL 结构。 + +## 开发阶段 SLI 与 SLO + +SLI 从事件首次得到 `result=RECEIVED` 开始,到下游首次成功确认结束;`DUPLICATE` 和 `CONFLICT` +不重复进入分母。滚动 30 天目标: + +- 99% 的新接收跨区事件在 60 秒内得到下游确认。 +- 99.9% 的新接收跨区事件在 15 分钟内得到下游确认。 + +代码暴露固定延迟桶,目标查询模板如下;按 `target_region` 汇总,必要时可保留有限 `event_type` 分组: + +```promql +sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="60.0"}[30d])) +/ +sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.99 +``` + +```promql +sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="900.0"}[30d])) +/ +sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.999 +``` + +## 告警合同 + +规则由实际监控平台按 30 秒或更短评估周期配置;以下只是表达式合同: + +- 新增 DEAD:下一次评估立即告警,不设置持续等待。 + `sum(increase(baiyue_event_bridge_transport_total{result="DEAD"}[1m])) > 0` +- 最老待处理年龄预警:任一有限标签序列超过 120 秒。 + `max(baiyue_event_bridge_inbox_oldest_age_seconds) > 120` +- 最老待处理年龄严重:任一有限标签序列超过 900 秒。 + `max(baiyue_event_bridge_inbox_oldest_age_seconds) > 900` +- 当前 DEAD 存量用于持续可见性:`sum(baiyue_event_bridge_inbox_dead) > 0`;它不替代“新增 DEAD”告警。 + +告警通知中可通过日志和业务查询再定位 event ID;禁止为了通知便利把高基数标识加入指标标签。 diff --git a/event-bridge-service/docs/event-trace-propagation.md b/event-bridge-service/docs/event-trace-propagation.md new file mode 100644 index 000000000..00c2e42c3 --- /dev/null +++ b/event-bridge-service/docs/event-trace-propagation.md @@ -0,0 +1,16 @@ +# Event Bridge Trace 传播基线 + +- Event Bridge 领取 Inbox 后,仅从事件信封已冻结的 `traceId` 建立投递线程 MDC。 +- 32 位非零十六进制 trace ID 直接沿用;历史规范 UUID 去除连字符后沿用。 +- 其他旧值不满足 W3C Trace ID 约束,投递时安全重建新 trace,但不覆盖或迁移原事件信封。 +- 每次 HTTP 投递生成新的 `X-Request-Id` 和 child span,并统一覆盖 `X-Trace-Id`、`traceparent`。 +- `correlationId`、`eventId`、customer ID、聚合 ID、JWT 和内部服务令牌不得写入 Trace 标准字段。 +- 投递日志在 MDC 和显式 `traceId` 字段中记录本次标准 trace;投递 attempt 仍通过 `event_id` + 关联 `bridge_inbox.trace_id`,本轮不增加数据库字段。 + +## 共享契约缺口 + +当前 `EventEnvelope.traceId` 仍是通用字符串契约,没有强制 32 位 W3C Trace ID;因此历史任意字符串不能 +无损映射为 W3C trace。若后续要求所有事件跨重试、跨语言绝对保持同一标准 trace,必须由 +`common-event-contract` 生产者、消费者和 Event Bridge 共同冻结字段校验后再收敛。本轮没有修改共享 +Payload、信封字段、哈希规则或可靠性状态机。 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java index dd0e0b068..a79fbbeb9 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java @@ -1,14 +1,19 @@ package com.baiyue.eventbridge; +import com.baiyue.eventbridge.config.EventDispatcherProperties; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 跨区域事件桥启动入口。 */ @MapperScan("com.baiyue.eventbridge.mapper") @SpringBootApplication +@EnableScheduling +@EnableConfigurationProperties(EventDispatcherProperties.class) public class EventBridgeApplication { public static void main(String[] args) { SpringApplication.run(EventBridgeApplication.class, args); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java new file mode 100644 index 000000000..fffbbea19 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java @@ -0,0 +1,34 @@ +package com.baiyue.eventbridge.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.net.URI; +import java.time.Duration; + +@ConfigurationProperties(prefix = "baiyue.event-dispatcher") +public record EventDispatcherProperties( + URI consumerUrl, + int batchSize, + int maxAttempts, + Duration leaseDuration, + Duration initialBackoff, + Duration maxBackoff, + Duration connectTimeout, + Duration requestTimeout) { + + public EventDispatcherProperties { + consumerUrl = consumerUrl == null + ? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl; + batchSize = batchSize <= 0 ? 50 : batchSize; + maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts; + leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30)); + initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5)); + maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15)); + connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3)); + requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10)); + } + + private static Duration positiveOrDefault(Duration value, Duration fallback) { + return value == null || value.isNegative() || value.isZero() ? fallback : value; + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java index af0707c23..7a2b939f0 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java @@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; import com.baiyue.eventbridge.dto.BridgeStatus; import com.baiyue.eventbridge.dto.EventReceiveResult; +import com.baiyue.eventbridge.dto.EventReplayRequest; import com.baiyue.eventbridge.service.EventInboxService; +import com.baiyue.eventbridge.service.EventReplayService; import com.baiyue.eventbridge.security.InternalServiceTokenGuard; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PathVariable; /** * 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。 @@ -26,16 +29,14 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/internal/event-bridge") public class EventBridgeController { - @Resource - private EventInboxService eventInboxService; + @Resource private EventInboxService eventInboxService; + @Resource private EventReplayService eventReplayService; + @Resource private InternalServiceTokenGuard internalServiceTokenGuard; - @Resource - private InternalServiceTokenGuard internalServiceTokenGuard; - - @Value("${baiyue.region:CN}") + @Value("${baiyue.region:OVERSEAS}") private String region; - @Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通") + @Operation(summary = "幂等接收事件", description = "按当前部署区域校验目标区域、事件信封与 payloadHash,幂等持久化后进入租约保护的重试投递队列") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "事件已接收或判定为重复"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "内部服务令牌无效"), @@ -50,6 +51,19 @@ public class EventBridgeController { return ApiResponse.success(eventInboxService.receive(envelope)); } + @Operation(summary = "人工补投死信事件", + description = "仅允许 DEAD 事件转回 RETRY;保留原 eventId、payload 与 payloadHash,必须记录操作人与补投原因") + @PostMapping("/events/{eventId}/replay") + public ApiResponse replay( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") String operatorId, + @PathVariable String eventId, + @RequestBody EventReplayRequest request) { + internalServiceTokenGuard.verify(token); + eventReplayService.replayDead(eventId, operatorId, request.reason()); + return ApiResponse.success(null); + } + @Operation(summary = "查询事件桥能力状态") @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功")) @GetMapping("/status") diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java new file mode 100644 index 000000000..eaf57c555 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java @@ -0,0 +1,24 @@ +package com.baiyue.eventbridge.domain; + +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("bridge_delivery_attempt") +public class BridgeDeliveryAttempt { + @TableId(type = IdType.AUTO) + private Long id; + private String eventId; + private String sourceRegion; + private String targetRegion; + private Integer attemptNo; + private String status; + private String responseCode; + private String errorMessage; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java index 4094be14e..96f15d531 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java @@ -20,7 +20,7 @@ public class BridgeInbox { private Integer eventVersion; private String sourceRegion; private String targetRegion; - private String tenantId; + private String customerId; private String aggregateType; private String aggregateId; private Long aggregateVersion; @@ -32,5 +32,11 @@ public class BridgeInbox { private String payloadHash; private String payloadJson; private String status; + private String leaseOwner; + private LocalDateTime leaseUntil; + private Integer retryCount; + private LocalDateTime nextRetryAt; + private String lastError; private LocalDateTime receivedAt; + private LocalDateTime processedAt; } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java new file mode 100644 index 000000000..25c4bb324 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java @@ -0,0 +1,4 @@ +package com.baiyue.eventbridge.dto; + +public record EventReplayRequest(String reason) { +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java new file mode 100644 index 000000000..755341428 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.eventbridge.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface BridgeDeliveryAttemptMapper extends BaseMapper { +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java index 5ec53602f..287a5faa0 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java @@ -2,11 +2,80 @@ package com.baiyue.eventbridge.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.metrics.InboxMetricsSnapshot; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; /** * 入站事件持久化映射。 */ @Mapper public interface BridgeInboxMapper extends BaseMapper { + @Select(""" + SELECT event_id FROM bridge_inbox + WHERE ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) + OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) + ORDER BY received_at LIMIT #{limit} + """) + List dispatchCandidates(@Param("limit") int limit); + + @Update(""" + UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner}, + lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND) + WHERE event_id=#{eventId} + AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) + OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) + """) + int claim(@Param("eventId") String eventId, @Param("owner") String owner, + @Param("leaseSeconds") long leaseSeconds); + + @Update(""" + UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL + WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner} + """) + int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner); + + @Update(""" + UPDATE bridge_inbox SET retry_count=retry_count+1, + status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END, + next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL + ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END, + lease_owner=NULL, lease_until=NULL, last_error=#{error} + WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner} + """) + int markFailed(@Param("eventId") String eventId, @Param("owner") String owner, + @Param("maxAttempts") int maxAttempts, + @Param("backoffSeconds") long backoffSeconds, @Param("error") String error); + + @Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}") + int nextAttemptNo(@Param("eventId") String eventId); + + @Update(""" + UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL, lease_until=NULL, last_error=#{reason} + WHERE event_id=#{eventId} AND status='DEAD' + """) + int replayDead(@Param("eventId") String eventId, @Param("reason") String reason); + + /** + * Scheduled observability aggregation. The status predicate uses idx_bridge_inbox_consume; + * Prometheus collection never invokes this query directly. + */ + @Select(""" + SELECT target_region AS targetRegion, event_type AS eventType, + SUM(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN 1 ELSE 0 END) AS backlogCount, + COALESCE(TIMESTAMPDIFF(MICROSECOND, + MIN(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN received_at END), + CURRENT_TIMESTAMP(3)) / 1000000.0, 0) AS oldestPendingAgeSeconds, + SUM(CASE WHEN status='DEAD' THEN 1 ELSE 0 END) AS deadCount + FROM bridge_inbox + WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD') + GROUP BY target_region, event_type + """) + List observabilitySnapshots(); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java new file mode 100644 index 000000000..667aaadb2 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java @@ -0,0 +1,103 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.MultiGauge; +import io.micrometer.core.instrument.Tags; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Periodically samples indexed Inbox aggregates; Prometheus scrapes only the cached gauges. */ +@Component +@Slf4j +public class EventBridgeInboxMetricsSampler { + public static final String BACKLOG_METRIC = "baiyue.event.bridge.inbox.backlog"; + public static final String OLDEST_AGE_METRIC = "baiyue.event.bridge.inbox.oldest.age.seconds"; + public static final String DEAD_METRIC = "baiyue.event.bridge.inbox.dead"; + + private final BridgeInboxMapper inboxMapper; + private final MultiGauge backlog; + private final MultiGauge oldestAge; + private final MultiGauge dead; + + public EventBridgeInboxMetricsSampler(BridgeInboxMapper inboxMapper, MeterRegistry registry) { + this.inboxMapper = inboxMapper; + this.backlog = MultiGauge.builder(BACKLOG_METRIC) + .description("Events waiting for downstream acknowledgement").register(registry); + this.oldestAge = MultiGauge.builder(OLDEST_AGE_METRIC) + .description("Age in seconds of the oldest pending Inbox event").register(registry); + this.dead = MultiGauge.builder(DEAD_METRIC) + .description("Current number of DEAD Inbox events").register(registry); + } + + @Scheduled(initialDelayString = "${baiyue.event-bridge.metrics.initial-delay:5000}", + fixedDelayString = "${baiyue.event-bridge.metrics.sample-delay:30000}") + public void sample() { + final List snapshots; + try { + snapshots = inboxMapper.observabilitySnapshots(); + } catch (RuntimeException exception) { + log.warn("Event Bridge Inbox 指标采样失败,保留上次成功快照", exception); + return; + } + Map aggregates = aggregate(snapshots); + backlog.register(rows(aggregates, MetricValue.BACKLOG), true); + oldestAge.register(rows(aggregates, MetricValue.OLDEST_AGE), true); + dead.register(rows(aggregates, MetricValue.DEAD), true); + } + + private Map aggregate(List snapshots) { + Map result = new LinkedHashMap<>(); + if (snapshots == null) { + return result; + } + for (InboxMetricsSnapshot snapshot : snapshots) { + MetricKey key = new MetricKey(EventBridgeMetrics.targetRegion(snapshot.getTargetRegion()), + EventBridgeMetrics.eventType(snapshot.getEventType())); + result.computeIfAbsent(key, ignored -> new Aggregate()).add(snapshot); + } + return result; + } + + private List> rows(Map aggregates, MetricValue value) { + List> rows = new ArrayList<>(); + aggregates.forEach((key, aggregate) -> rows.add(MultiGauge.Row.of( + Tags.of("target_region", key.targetRegion, "event_type", key.eventType), + value.value(aggregate)))); + return rows; + } + + private record MetricKey(String targetRegion, String eventType) { } + + private static final class Aggregate { + private long backlog; + private double oldestAge; + private long dead; + + void add(InboxMetricsSnapshot snapshot) { + backlog += snapshot.getBacklogCount(); + oldestAge = Math.max(oldestAge, snapshot.getOldestPendingAgeSeconds()); + dead += snapshot.getDeadCount(); + } + } + + private enum MetricValue { + BACKLOG { + @Override Number value(Aggregate aggregate) { return aggregate.backlog; } + }, + OLDEST_AGE { + @Override Number value(Aggregate aggregate) { return aggregate.oldestAge; } + }, + DEAD { + @Override Number value(Aggregate aggregate) { return aggregate.dead; } + }; + + abstract Number value(Aggregate aggregate); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java new file mode 100644 index 000000000..85756ae0e --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -0,0 +1,123 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.eventbridge.domain.BridgeInbox; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Set; + +/** Low-cardinality transport metrics for the cross-region event bridge. */ +@Component +@Slf4j +public class EventBridgeMetrics { + public static final String RECEIVE_METRIC = "baiyue.event.bridge.receive"; + public static final String TRANSPORT_METRIC = "baiyue.event.bridge.transport"; + public static final String DELIVERY_LATENCY_METRIC = "baiyue.event.bridge.delivery.latency"; + + private static final Set KNOWN_EVENT_TYPES = Set.of( + EventTypes.ORDER_APPROVED, + EventTypes.ORDER_CANCELLED, + EventTypes.FIRST_LEG_SHIPMENT_DEPARTED, + EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, + EventTypes.OVERSEAS_CONTAINER_UNLOADED, + EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, + EventTypes.IAM_PERMISSION_VERSION_CHANGED, + EventTypes.LAST_MILE_HANDOFF_ACCEPTED, + EventTypes.LAST_MILE_HANDOFF_REJECTED, + EventTypes.LAST_MILE_MILESTONE_CHANGED, + EventTypes.LAST_MILE_COMPLETED, + EventTypes.TRACKING_MILESTONE_PROJECTED); + + private final MeterRegistry registry; + private final Clock clock; + + @Autowired + public EventBridgeMetrics(MeterRegistry registry) { + this(registry, Clock.systemDefaultZone()); + } + + EventBridgeMetrics(MeterRegistry registry, Clock clock) { + this.registry = registry; + this.clock = clock; + } + + public void recordReceive(EventEnvelope envelope, ReceiveResult result) { + try { + registry.counter(RECEIVE_METRIC, + "target_region", targetRegion(envelope.targetRegion().name()), + "event_type", eventType(envelope.eventType()), + "result", result.name()).increment(); + } catch (RuntimeException exception) { + log.warn("Event Bridge 接收指标记录失败,不影响事件事务", exception); + } + } + + public void recordTransport(BridgeInbox event, TransportResult result) { + try { + String targetRegion = targetRegion(event.getTargetRegion()); + String eventType = eventType(event.getEventType()); + registry.counter(TRANSPORT_METRIC, + "target_region", targetRegion, + "event_type", eventType, + "result", result.name()).increment(); + if (result.acknowledged() && event.getReceivedAt() != null) { + Duration latency = Duration.between(event.getReceivedAt(), LocalDateTime.now(clock)); + if (latency.isNegative()) { + latency = Duration.ZERO; + } + Timer.builder(DELIVERY_LATENCY_METRIC) + .description("Time from Event Bridge receipt to downstream acknowledgement") + .serviceLevelObjectives(Duration.ofSeconds(60), Duration.ofMinutes(15)) + .tags("target_region", targetRegion, "event_type", eventType) + .register(registry).record(latency); + } + } catch (RuntimeException exception) { + log.warn("Event Bridge 传输指标记录失败,不影响可靠性状态机", exception); + } + } + + public static String targetRegion(String value) { + String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + return "CN".equals(normalized) || "OVERSEAS".equals(normalized) ? normalized : "OTHER"; + } + + public static String eventType(String value) { + return value != null && KNOWN_EVENT_TYPES.contains(value) ? value : "OTHER"; + } + + public enum ReceiveResult { + RECEIVED, + DUPLICATE, + CONFLICT + } + + public enum TransportResult { + DELIVERED(true), + RETRY(false), + DEAD(false), + ACKNOWLEDGED_LEASE_LOST(true), + FAILED_LEASE_LOST(false), + MANUAL_REPLAY(false); + + private final boolean acknowledged; + + TransportResult(boolean acknowledged) { + this.acknowledged = acknowledged; + } + + boolean acknowledged() { + return acknowledged; + } + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java new file mode 100644 index 000000000..24e89b303 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java @@ -0,0 +1,50 @@ +package com.baiyue.eventbridge.metrics; + +/** Result row of the bounded, scheduled Inbox observability aggregation. */ +public class InboxMetricsSnapshot { + private String targetRegion; + private String eventType; + private long backlogCount; + private double oldestPendingAgeSeconds; + private long deadCount; + + public String getTargetRegion() { + return targetRegion; + } + + public void setTargetRegion(String targetRegion) { + this.targetRegion = targetRegion; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public long getBacklogCount() { + return backlogCount; + } + + public void setBacklogCount(long backlogCount) { + this.backlogCount = backlogCount; + } + + public double getOldestPendingAgeSeconds() { + return oldestPendingAgeSeconds; + } + + public void setOldestPendingAgeSeconds(double oldestPendingAgeSeconds) { + this.oldestPendingAgeSeconds = oldestPendingAgeSeconds; + } + + public long getDeadCount() { + return deadCount; + } + + public void setDeadCount(long deadCount) { + this.deadCount = deadCount; + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java index 84f9ee1ab..ba4717409 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java @@ -13,8 +13,11 @@ import java.security.MessageDigest; */ @Component public class InternalServiceTokenGuard { - @Value("${baiyue.security.internal-service-token:}") - private String expectedToken; + private final String expectedToken; + + public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) { + this.expectedToken = expectedToken; + } public void verify(String actualToken) { if (expectedToken == null || expectedToken.isBlank()) { diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 8b68a2789..f904c7984 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -1,20 +1,22 @@ package com.baiyue.eventbridge.service; -import jakarta.annotation.Resource; - import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.dto.EventReceiveResult; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.Objects; /** @@ -22,12 +24,12 @@ import java.util.Objects; */ @Service public class EventInboxService { - @Resource - private InboxStore inboxStore; - @Resource - private ObjectMapper objectMapper; + @Resource private InboxStore inboxStore; + @Resource private ObjectMapper objectMapper; + @Resource private EventBridgeMetrics metrics; + @Value("${baiyue.region:CN}") - private String localRegion; + private String localRegion; @Transactional public EventReceiveResult receive(EventEnvelope envelope) { @@ -35,7 +37,8 @@ public class EventInboxService { if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致"); } - if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + if (!EventPayloadHash.matches( + EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致"); } BridgeInbox existing = inboxStore.findByEventId(envelope.eventId()); @@ -45,6 +48,7 @@ public class EventInboxService { BridgeInbox inbox = toInbox(envelope); try { inboxStore.insert(inbox); + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.RECEIVED); return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt()); } catch (DuplicateKeyException exception) { BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId()); @@ -56,25 +60,30 @@ public class EventInboxService { } private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { - if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { - throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件"); - } - if (!hasSameEnvelopeIdentity(existing, envelope)) { - throw new IllegalArgumentException("相同 eventId 对应的客户或事件信封身份不一致,拒绝覆盖既有事件"); + if (!sameEnvelope(existing, envelope)) { + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT); + throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件"); } + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE); return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); } - private boolean hasSameEnvelopeIdentity(BridgeInbox existing, EventEnvelope envelope) { - return Objects.equals(existing.getTenantId(), envelope.tenantId()) - && Objects.equals(existing.getEventType(), envelope.eventType()) - && Objects.equals(existing.getEventVersion(), envelope.eventVersion()) - && Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name()) - && Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name()) - && Objects.equals(existing.getAggregateType(), envelope.aggregateType()) - && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) - && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) - && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()); + private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) { + return existing.getPayloadHash() != null + && existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash()) + && Objects.equals(existing.getEventType(), envelope.eventType()) + && Objects.equals(existing.getEventVersion(), envelope.eventVersion()) + && Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name()) + && Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name()) + && Objects.equals(existing.getCustomerId(), envelope.tenantId()) + && Objects.equals(existing.getAggregateType(), envelope.aggregateType()) + && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) + && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) + && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()) + && Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt())) + && Objects.equals(existing.getTraceId(), envelope.traceId()) + && Objects.equals(existing.getCorrelationId(), envelope.correlationId()) + && Objects.equals(existing.getCausationId(), envelope.causationId()); } private BridgeInbox toInbox(EventEnvelope envelope) { @@ -85,7 +94,7 @@ public class EventInboxService { inbox.setEventVersion(envelope.eventVersion()); inbox.setSourceRegion(envelope.sourceRegion().name()); inbox.setTargetRegion(envelope.targetRegion().name()); - inbox.setTenantId(envelope.tenantId()); + inbox.setCustomerId(envelope.tenantId()); inbox.setAggregateType(envelope.aggregateType()); inbox.setAggregateId(envelope.aggregateId()); inbox.setAggregateVersion(envelope.aggregateVersion()); @@ -93,7 +102,7 @@ public class EventInboxService { inbox.setTraceId(envelope.traceId()); inbox.setCorrelationId(envelope.correlationId()); inbox.setCausationId(envelope.causationId()); - inbox.setOccurredAt(envelope.occurredAt()); + inbox.setOccurredAt(normalizeOccurredAt(envelope.occurredAt())); inbox.setPayloadHash(envelope.payloadHash().toLowerCase(java.util.Locale.ROOT)); try { inbox.setPayloadJson(objectMapper.writeValueAsString(envelope.payload())); @@ -104,4 +113,8 @@ public class EventInboxService { inbox.setReceivedAt(LocalDateTime.now()); return inbox; } + + private static Instant normalizeOccurredAt(Instant value) { + return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS); + } } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java new file mode 100644 index 000000000..632672e95 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java @@ -0,0 +1,53 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +public class EventReplayService { + @Resource private BridgeInboxMapper inboxMapper; + @Resource private BridgeDeliveryAttemptMapper attemptMapper; + @Resource private EventBridgeMetrics metrics; + + @Transactional + public void replayDead(String eventId, String operatorId, String reason) { + if (operatorId == null || operatorId.isBlank()) { + throw new IllegalArgumentException("人工补投必须提供操作人"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("人工补投必须提供原因"); + } + String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000); + if (inboxMapper.replayDead(eventId, auditReason) != 1) { + throw new IllegalStateException("事件不存在或未处于 DEAD 状态"); + } + BridgeInbox event = inboxMapper.selectById(eventId); + if (event == null) { + throw new IllegalStateException("事件补投状态已更新但事件记录不存在"); + } + LocalDateTime now = LocalDateTime.now(); + BridgeDeliveryAttempt audit = new BridgeDeliveryAttempt(); + audit.setEventId(eventId); + audit.setSourceRegion(event.getSourceRegion()); + audit.setTargetRegion(event.getTargetRegion()); + audit.setAttemptNo(inboxMapper.nextAttemptNo(eventId)); + audit.setStatus("MANUAL_REPLAY"); + audit.setErrorMessage(auditReason); + audit.setStartedAt(now); + audit.setFinishedAt(now); + attemptMapper.insert(audit); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.MANUAL_REPLAY); + } + + private static String abbreviate(String value, int maxLength) { + return value.length() <= maxLength ? value : value.substring(0, maxLength); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java new file mode 100644 index 000000000..a0bb0e33e --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface FirstlegEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java new file mode 100644 index 000000000..f888cd04f --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java @@ -0,0 +1,75 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Component +public class HttpFirstlegEventClient implements FirstlegEventClient { + @Resource private ObjectMapper objectMapper; + @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; + @Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}") + private URI consumerUrl; + @Value("${baiyue.security.internal-service-token:}") private String serviceToken; + private HttpClient httpClient; + + @PostConstruct + void initializeHttpClient() { + httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); + } + + @Override + public String deliver(BridgeInbox row) throws Exception { + EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), + row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), + row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), + row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), + objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + .header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode responseBody; + try { + responseBody = objectMapper.readTree(response.body()); + } catch (Exception ignored) { + responseBody = null; + } + if (response.statusCode() / 100 != 2 || responseBody == null + || !"200".equals(responseBody.path("code").asText())) { + throw new IllegalStateException(failureMessage(response.statusCode(), responseBody)); + } + return String.valueOf(response.statusCode()); + } + + static String failureMessage(int statusCode, JsonNode body) { + String code = body == null ? "missing" : body.path("code").asText("missing"); + String message = body == null ? "non-json response" : body.path("message").asText("missing"); + String requestId = body == null ? "missing" : body.path("requestId").asText("missing"); + String traceId = body == null ? "missing" : body.path("traceId").asText("missing"); + return "头程消费失败: HTTP " + statusCode + ", code=" + bounded(code) + + ", message=" + bounded(message) + ", requestId=" + bounded(requestId) + + ", traceId=" + bounded(traceId); + } + + private static String bounded(String value) { + String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " "); + return safe.length() <= 240 ? safe : safe.substring(0, 240); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java new file mode 100644 index 000000000..2e01d3981 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java @@ -0,0 +1,78 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Component +public class HttpLastlegEventClient implements LastlegEventClient { + @Resource private ObjectMapper objectMapper; + @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; + + @Value("${baiyue.security.internal-service-token:}") + private String serviceToken; + + private HttpClient httpClient; + + @PostConstruct + void initializeHttpClient() { + this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); + } + + @Override + public String deliver(BridgeInbox row) throws Exception { + EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), + row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), + row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), + row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), + objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); + String body = objectMapper.writeValueAsString(envelope); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(properties.consumerUrl()) + .timeout(properties.requestTimeout()) + .header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode responseBody; + try { + responseBody = objectMapper.readTree(response.body()); + } catch (Exception ignored) { + responseBody = null; + } + if (response.statusCode() / 100 != 2 || responseBody == null + || !"200".equals(responseBody.path("code").asText())) { + throw new IllegalStateException(failureMessage(response.statusCode(), responseBody)); + } + return String.valueOf(response.statusCode()); + } + + static String failureMessage(int statusCode, JsonNode body) { + String code = body == null ? "missing" : body.path("code").asText("missing"); + String message = body == null ? "non-json response" : body.path("message").asText("missing"); + String requestId = body == null ? "missing" : body.path("requestId").asText("missing"); + String traceId = body == null ? "missing" : body.path("traceId").asText("missing"); + return "尾程消费失败: HTTP " + statusCode + ", code=" + bounded(code) + + ", message=" + bounded(message) + ", requestId=" + bounded(requestId) + + ", traceId=" + bounded(traceId); + } + + private static String bounded(String value) { + String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " "); + return safe.length() <= 240 ? safe : safe.substring(0, 240); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java new file mode 100644 index 000000000..9e55d56bd --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java @@ -0,0 +1,64 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Component +public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient { + private final ObjectMapper objectMapper; + private final EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; + @Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl; + @Value("${baiyue.security.internal-service-token:}") private String serviceToken; + private HttpClient httpClient; + + public HttpSecurityProjectionEventClient(ObjectMapper objectMapper, EventDispatcherProperties properties) { + this.objectMapper = objectMapper; + this.properties = properties; + } + + @PostConstruct void initialize() { + httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); + } + + @Override + public String deliver(BridgeInbox row) throws Exception { + EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), + row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), + row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), + row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), + objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + .header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode body; + try { body = objectMapper.readTree(response.body()); } + catch (Exception exception) { body = null; } + requireAcknowledgement(response.statusCode(), body, row.getEventId()); + return String.valueOf(response.statusCode()); + } + + static void requireAcknowledgement(int statusCode, JsonNode body, String eventId) { + if (statusCode / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) + || !eventId.equals(body.path("data").path("eventId").asText())) { + throw new IllegalStateException("Lastleg 安全投影消费失败: HTTP " + statusCode); + } + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java new file mode 100644 index 000000000..7fa21d3b3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface LastlegEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java index b397cae4c..d68c0f983 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java @@ -2,16 +2,15 @@ package com.baiyue.eventbridge.service; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; /** * 基于 MyBatis-Plus 的入站幂等存储。 */ @Repository -@RequiredArgsConstructor public class MybatisInboxStore implements InboxStore { - private final BridgeInboxMapper mapper; + @Resource private BridgeInboxMapper mapper; @Override public BridgeInbox findByEventId(String eventId) { diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java new file mode 100644 index 000000000..b20be547f --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -0,0 +1,135 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventTypes; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import com.baiyue.eventbridge.trace.EventBridgeTraceScope; +import com.baiyue.common.web.CorrelationContext; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.slf4j.MDC; + +import java.lang.management.ManagementFactory; +import java.time.LocalDateTime; +import java.util.Set; +import java.util.UUID; + +@Component +@Slf4j +public class OverseasEventDispatcher { + @Resource private BridgeInboxMapper inboxMapper; + @Resource private BridgeDeliveryAttemptMapper attemptMapper; + @Resource private LastlegEventClient eventClient; + @Resource private FirstlegEventClient firstlegEventClient; + @Resource private SecurityProjectionEventClient securityProjectionEventClient; + @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeMetrics metrics; + private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); + + @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}") + public void dispatch() { + for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) { + dispatchOne(eventId); + } + } + + void dispatchOne(String eventId) { + if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) { + return; + } + + BridgeInbox event = inboxMapper.selectById(eventId); + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + dispatchClaimed(eventId, event); + } + } + + private void dispatchClaimed(String eventId, BridgeInbox event) { + int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; + int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); + BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); + String responseCode; + try { + responseCode = deliver(event); + } catch (Exception exception) { + String error = abbreviate(exception.getMessage(), 1000); + int marked = inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), + backoffSeconds(retryAttempt), error); + String attemptStatus = marked == 1 + ? (retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY") + : "FAILED_LEASE_LOST"; + finishAttempt(attempt, attemptStatus, null, error); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.valueOf(attemptStatus)); + log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, traceId={}, reason={}", + eventId, retryAttempt, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID), error); + return; + } + + if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { + String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投"; + finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST); + log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}, traceId={}", + eventId, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID)); + return; + } + finishAttempt(attempt, "DELIVERED", responseCode, null); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.DELIVERED); + } + + private String deliver(BridgeInbox event) throws Exception { + String eventType = event.getEventType(); + if (eventType == null || eventType.isBlank()) { + throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者"); + } + return switch (event.getTargetRegion()) { + case "CN" -> firstlegEventClient.deliver(event); + case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType) + ? securityProjectionEventClient.deliver(event) : eventClient.deliver(event); + default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion()); + }; + } + + private static final Set SECURITY_EVENT_TYPES = Set.of( + EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, + EventTypes.IAM_PERMISSION_VERSION_CHANGED); + + private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { + BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); + attempt.setEventId(event.getEventId()); + attempt.setSourceRegion(event.getSourceRegion()); + attempt.setTargetRegion(event.getTargetRegion()); + attempt.setAttemptNo(attemptNo); + attempt.setStatus("PROCESSING"); + attempt.setStartedAt(LocalDateTime.now()); + attemptMapper.insert(attempt); + return attempt; + } + + private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) { + attempt.setStatus(status); + attempt.setResponseCode(responseCode); + attempt.setErrorMessage(error); + attempt.setFinishedAt(LocalDateTime.now()); + attemptMapper.updateById(attempt); + } + + private long backoffSeconds(int attemptNo) { + long initial = properties.initialBackoff().toSeconds(); + int shift = Math.min(Math.max(attemptNo - 1, 0), 20); + long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift; + return Math.min(delay, properties.maxBackoff().toSeconds()); + } + + private static String abbreviate(String value, int maxLength) { + String safe = value == null || value.isBlank() ? "未知投递错误" : value; + return safe.length() <= maxLength ? safe : safe.substring(0, maxLength); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java new file mode 100644 index 000000000..87fd9e3eb --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface SecurityProjectionEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java new file mode 100644 index 000000000..dc2c78bd3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java @@ -0,0 +1,19 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.common.web.OutboundTraceHeaders; +import org.springframework.stereotype.Component; + +import java.net.http.HttpRequest; + +/** Applies a fresh child span to one raw JDK HttpClient request. */ +@Component +public class EventBridgeHttpTracePropagation { + public HttpRequest.Builder apply(HttpRequest.Builder builder) { + OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew(); + return builder + .setHeader(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId()) + .setHeader(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()) + .setHeader(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java new file mode 100644 index 000000000..82139ab51 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java @@ -0,0 +1,35 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.common.web.OutboundTraceHeaders; +import com.baiyue.eventbridge.domain.BridgeInbox; +import org.slf4j.MDC; + +import java.util.Map; + +/** Establishes one trusted MDC context for an Event Bridge delivery attempt. */ +public final class EventBridgeTraceScope implements AutoCloseable { + private final Map previous; + + private EventBridgeTraceScope(Map previous) { + this.previous = previous; + } + + public static EventBridgeTraceScope open(BridgeInbox event) { + Map previous = MDC.getCopyOfContextMap(); + OutboundTraceHeaders local = OutboundTraceHeaders.fromTrustedTraceOrNew(event.getTraceId()); + MDC.put(CorrelationContext.REQUEST_ID, local.requestId()); + MDC.put(CorrelationContext.TRACE_ID, local.traceId()); + MDC.put(CorrelationContext.SPAN_ID, local.spanId()); + return new EventBridgeTraceScope(previous); + } + + @Override + public void close() { + if (previous == null) { + MDC.clear(); + } else { + MDC.setContextMap(previous); + } + } +} diff --git a/event-bridge-service/src/main/resources/application-dev.yml b/event-bridge-service/src/main/resources/application-dev.yml deleted file mode 100644 index 19d0a9ad0..000000000 --- a/event-bridge-service/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +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} diff --git a/event-bridge-service/src/main/resources/application-test.yml b/event-bridge-service/src/main/resources/application-test.yml deleted file mode 100644 index 6b8765c21..000000000 --- a/event-bridge-service/src/main/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${EVENT_BRIDGE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${EVENT_BRIDGE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${EVENT_BRIDGE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index 834b47a8d..b20743a41 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -1,15 +1,22 @@ server: - port: ${EVENT_BRIDGE_PORT:9300} + port: ${EVENT_BRIDGE_PORT:9312} spring: - profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} application: name: event-bridge-service config: import: - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + 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} cloud: nacos: server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} @@ -22,7 +29,19 @@ spring: namespace: ${NACOS_NAMESPACE:} baiyue: - region: ${REGION_CODE:CN} + region: ${REGION_CODE:OVERSEAS} + event-dispatcher: + consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} + firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} + security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1: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} security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:} @@ -35,14 +54,3 @@ management: springdoc: api-docs: path: /v3/api-docs - -logging: - file: - name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" - logback: - rollingpolicy: - file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" - max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" - total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" - clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/event-bridge-service/src/main/resources/db/event-bridge.sql b/event-bridge-service/src/main/resources/db/event-bridge.sql index a7a52b539..4bbfe6bd4 100644 --- a/event-bridge-service/src/main/resources/db/event-bridge.sql +++ b/event-bridge-service/src/main/resources/db/event-bridge.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '租户标识', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', @@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '租户标识', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java new file mode 100644 index 000000000..a18746bd5 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.common.event.EventTypes; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class EventBridgeInboxMetricsSamplerTest { + + @Test + void samplesDatabaseOnceAndServesCachedLowCardinalityGauges() { + BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + InboxMetricsSnapshot known = snapshot("OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + 7, 121.5, 2); + InboxMetricsSnapshot unknown = snapshot("region-123", "dynamic-event-123", 3, 900.0, 1); + when(mapper.observabilitySnapshots()).thenReturn(List.of(known, unknown)); + EventBridgeInboxMetricsSampler sampler = new EventBridgeInboxMetricsSampler(mapper, registry); + + sampler.sample(); + + verify(mapper).observabilitySnapshots(); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.BACKLOG_METRIC, + "OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(7.0); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.OLDEST_AGE_METRIC, + "OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(121.5); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.DEAD_METRIC, + "OTHER", "OTHER")).isEqualTo(1.0); + verifyNoMoreInteractions(mapper); + } + + private InboxMetricsSnapshot snapshot(String region, String eventType, long backlog, + double oldestAge, long dead) { + InboxMetricsSnapshot snapshot = new InboxMetricsSnapshot(); + snapshot.setTargetRegion(region); + snapshot.setEventType(eventType); + snapshot.setBacklogCount(backlog); + snapshot.setOldestPendingAgeSeconds(oldestAge); + snapshot.setDeadCount(dead); + return snapshot; + } + + private double gauge(SimpleMeterRegistry registry, String name, String region, String eventType) { + return registry.get(name).tags("target_region", region, "event_type", eventType).gauge().value(); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java new file mode 100644 index 000000000..636db964f --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java @@ -0,0 +1,78 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.domain.BridgeInbox; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Modifier; +import java.util.Arrays; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; + +class EventBridgeMetricsTest { + + @Test + void productionConstructorIsExplicitForSpringWhenTestClockConstructorAlsoExists() throws Exception { + assertThat(EventBridgeMetrics.class.getConstructor(MeterRegistry.class) + .isAnnotationPresent(Autowired.class)).isTrue(); + } + + @Test + void springContextCreatesMetricsBeanWithTheAnnotatedProductionConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean(MeterRegistry.class, SimpleMeterRegistry::new); + context.register(EventBridgeMetrics.class); + context.refresh(); + + assertThat(context.getBean(EventBridgeMetrics.class)).isNotNull(); + } + } + + @Test + void collapsesUnboundedRegionAndEventTypeLabelsToOther() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + EventBridgeMetrics metrics = new EventBridgeMetrics(registry); + BridgeInbox event = new BridgeInbox(); + event.setTargetRegion("customer-controlled-region"); + event.setEventType("dynamic-event-123456"); + + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY); + + assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", "OTHER", "event_type", "OTHER", "result", "RETRY") + .counter().count()).isEqualTo(1.0); + assertThat(registry.getMeters()).allSatisfy(meter -> assertThat(meter.getId().getTags()) + .noneMatch(tag -> tag.getValue().contains("dynamic-event"))); + assertThat(EventBridgeMetrics.eventType(null)).isEqualTo("OTHER"); + } + + @Test + void everyFrozenEventTypeHasABoundedTagValue() { + assertThat(Arrays.stream(com.baiyue.common.event.EventTypes.class.getFields()) + .filter(field -> Modifier.isStatic(field.getModifiers()) && field.getType() == String.class) + .map(field -> { + try { + return (String) field.get(null); + } catch (IllegalAccessException exception) { + throw new AssertionError(exception); + } + })).allMatch(value -> EventBridgeMetrics.eventType(value).equals(value)); + } + + @Test + void metricsFailureDoesNotChangeTransportControlFlow() { + EventBridgeMetrics metrics = new EventBridgeMetrics(mock(MeterRegistry.class)); + BridgeInbox event = new BridgeInbox(); + event.setTargetRegion("CN"); + event.setEventType(com.baiyue.common.event.EventTypes.OVERSEAS_CONTAINER_UNLOADED); + + assertThatCode(() -> metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY)) + .doesNotThrowAnyException(); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java new file mode 100644 index 000000000..5c106df15 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeObservabilityContractTest { + + @Test + void aggregationIsScheduledCacheInputAndUsesExistingStatusIndexBasis() throws Exception { + Select annotation = BridgeInboxMapper.class.getMethod("observabilitySnapshots") + .getAnnotation(Select.class); + String query = String.join(" ", annotation.value()).replaceAll("\\s+", " "); + String schema = Files.readString(Path.of("src/main/resources/db/event-bridge.sql")); + + assertThat(query) + .contains("WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')") + .contains("GROUP BY target_region, event_type") + .doesNotContain("event_id AS", "customer_id AS", "last_error AS"); + assertThat(schema).contains( + "KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)"); + } + + @Test + void developmentSloAndAlertThresholdsAreFrozenWithoutDeploymentClaim() throws Exception { + String contract = Files.readString(Path.of("docs/development-observability-slo.md")); + + assertThat(contract) + .contains("99% 的新接收跨区事件在 60 秒内") + .contains("99.9% 的新接收跨区事件在 15 分钟内") + .contains("result=\"DEAD\"", "> 120", "> 900") + .contains("不表示任何环境已经部署") + .contains("idx_bridge_inbox_consume(status, next_retry_at, lease_until)") + .doesNotContain("customer_id\"", "event_id\""); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java index 5c70836e7..1b8dfb9d2 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java @@ -1,43 +1,29 @@ package com.baiyue.eventbridge.security; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.AfterEach; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@DisplayName("内部服务令牌校验") -@SpringJUnitConfig(InternalServiceTokenGuard.class) -@TestPropertySource(properties = "baiyue.security.internal-service-token=a-secure-internal-token") class InternalServiceTokenGuardTest { - @Resource private InternalServiceTokenGuard guard; - - @AfterEach - void resetToken() { - ReflectionTestUtils.setField(guard, "expectedToken", "a-secure-internal-token"); - } @Test - void validTokenAllowsAccess() { + void 正确令牌允许访问() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token"); assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException(); } @Test - void unconfiguredTokenDisablesReceiveEndpoint() { - ReflectionTestUtils.setField(guard, "expectedToken", ""); + void 未配置令牌时默认关闭接收接口() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard(""); assertThatThrownBy(() -> guard.verify("anything")) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("接口已关闭"); } @Test - void invalidTokenIsRejected() { - ReflectionTestUtils.setField(guard, "expectedToken", "expected"); + void 错误令牌被拒绝() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected"); assertThatThrownBy(() -> guard.verify("wrong")) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("令牌无效"); diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java new file mode 100644 index 000000000..0869ea824 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java @@ -0,0 +1,39 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeReliabilitySqlContractTest { + @Test + void schedulingAndLeaseSqlCoverRetryDeadAndExpiredLeaseTakeover() throws Exception { + String candidates = sql(BridgeInboxMapper.class.getMethod("dispatchCandidates", int.class), Select.class); + String claim = sql(BridgeInboxMapper.class.getMethod( + "claim", String.class, String.class, long.class), Update.class); + String failed = sql(BridgeInboxMapper.class.getMethod( + "markFailed", String.class, String.class, int.class, long.class, String.class), Update.class); + String processed = sql(BridgeInboxMapper.class.getMethod( + "markProcessed", String.class, String.class), Update.class); + + assertThat(candidates).contains("status IN ('RECEIVED','RETRY')", "next_retry_at <= CURRENT_TIMESTAMP(3)", + "status = 'PROCESSING'", "lease_until < CURRENT_TIMESTAMP(3)", "ORDER BY received_at"); + assertThat(claim).contains("status='PROCESSING'", "lease_owner=#{owner}", "lease_until=DATE_ADD", + "status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)"); + assertThat(failed).contains("retry_count=retry_count+1", "status=CASE", "THEN 'DEAD' ELSE 'RETRY'", + "next_retry_at=CASE", "THEN NULL", "last_error=#{error}", "lease_owner=#{owner}"); + assertThat(processed).contains("status='PROCESSED'", "next_retry_at=NULL", "last_error=NULL", + "status='PROCESSING'", "lease_owner=#{owner}"); + } + + private String sql(Method method, Class type) { + A annotation = method.getAnnotation(type); + String[] statements = annotation instanceof Select select ? select.value() : ((Update) annotation).value(); + return String.join(" ", Arrays.asList(statements)).replaceAll("\\s+", " "); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index a0e9e5a42..6789ed520 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -5,17 +5,11 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.AfterEach; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.dao.DuplicateKeyException; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; @@ -25,102 +19,108 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@DisplayName("跨区域事件收件箱") -@SpringJUnitConfig(classes = {EventInboxService.class, EventInboxServiceTest.TestConfig.class}) -@TestPropertySource(properties = "baiyue.region=OVERSEAS") class EventInboxServiceTest { - @Resource - private ObjectMapper objectMapper; - @MockBean - private InboxStore store; - @Resource - private EventInboxService service; - - @Configuration(proxyBeanMethods = false) - static class TestConfig { - @Bean - ObjectMapper objectMapper() { - return new ObjectMapper(); - } - } - - @AfterEach - void resetRegion() { - ReflectionTestUtils.setField(service, "localRegion", "OVERSEAS"); - } + private final ObjectMapper objectMapper = new ObjectMapper(); + private final InboxStore store = mock(InboxStore.class); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); + private final EventInboxService service = service("OVERSEAS"); @Test - void firstEventPersistsAndReturnsReceived() { + void 首次事件落库并返回已接收() { EventEnvelope envelope = envelope("sha256:placeholder"); envelope = withHash(envelope); assertThat(service.receive(envelope).result()).isEqualTo("已接收"); verify(store).insert(any(BridgeInbox.class)); + assertThat(receiveCount("RECEIVED")).isEqualTo(1.0); } @Test - void identicalEventAndDigestDoesNotPersistAgain() { + void 拒绝旧数字规范化摘要() { + EventEnvelope source = envelope("sha256:placeholder"); + source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150); + String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload())); + EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), + source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash); + + assertThatThrownBy(() -> service.receive(envelope)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("payloadHash"); + } + + @Test + void 相同事件和摘要重复接收不再次写入() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); BridgeInbox existing = existing(envelope, envelope.payloadHash()); when(store.findByEventId(envelope.eventId())).thenReturn(existing); + assertThat(service.receive(envelope).result()).isEqualTo("重复"); + assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0); + } + + @Test + void 数据库毫秒精度截断后相同事件仍判定为重复() { + EventEnvelope source = envelope("sha256:placeholder"); + EventEnvelope precise = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + Instant.parse("2026-07-29T08:30:00.123456789Z"), source.sourceRegion(), source.targetRegion(), + source.tenantId(), source.aggregateType(), source.aggregateId(), source.aggregateVersion(), + source.handoverVersion(), source.traceId(), source.correlationId(), source.causationId(), + source.payload(), source.payloadHash()); + EventEnvelope envelope = withHash(precise); + BridgeInbox existing = existing(envelope, envelope.payloadHash()); + existing.setOccurredAt(Instant.parse("2026-07-29T08:30:00.123Z")); + when(store.findByEventId(envelope.eventId())).thenReturn(existing); + assertThat(service.receive(envelope).result()).isEqualTo("重复"); } @Test - void sameEventWithDifferentDigestRejectsOverwrite() { + void 相同事件但摘要不同时拒绝覆盖() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())) - .thenReturn(existing(envelope, "sha256:" + "0".repeat(64))); + when(store.findByEventId(envelope.eventId())).thenReturn(existing(envelope, "sha256:" + "0".repeat(64))); assertThatThrownBy(() -> service.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("拒绝覆盖"); + assertThat(receiveCount("CONFLICT")).isEqualTo(1.0); } @Test - void sameEventAndPayloadWithDifferentTenantRejectsOverwrite() { - EventEnvelope original = withHash(envelope("sha256:placeholder")); - EventEnvelope otherTenant = withTenant(original, "20002"); - when(store.findByEventId(otherTenant.eventId())) - .thenReturn(existing(original, original.payloadHash())); - - assertThatThrownBy(() -> service.receive(otherTenant)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("信封身份不一致"); - } - - @Test - void sameEventAndPayloadWithDifferentAggregateVersionRejectsOverwrite() { - EventEnvelope original = withHash(envelope("sha256:placeholder")); - EventEnvelope otherEnvelope = withAggregateVersion(original, 2L); - when(store.findByEventId(otherEnvelope.eventId())) - .thenReturn(existing(original, original.payloadHash())); - - assertThatThrownBy(() -> service.receive(otherEnvelope)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("信封身份不一致"); - } - - @Test - void duplicateInsertAfterRaceIsTreatedAsDuplicate() { + void 相同事件和摘要但聚合标识不同时拒绝覆盖() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())) - .thenReturn(null, existing(envelope, envelope.payloadHash())); + EventEnvelope conflicting = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(), + envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), + envelope.aggregateType(), "DIFFERENT", envelope.aggregateVersion(), envelope.handoverVersion(), + envelope.traceId(), envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash()); + when(store.findByEventId(conflicting.eventId())).thenReturn(existing(envelope, envelope.payloadHash())); + + assertThatThrownBy(() -> service.receive(conflicting)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("事件信封"); + } + + @Test + void 并发插入冲突后按既有记录判定为重复() { + EventEnvelope envelope = withHash(envelope("sha256:placeholder")); + when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope, envelope.payloadHash())); doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any()); assertThat(service.receive(envelope).result()).isEqualTo("重复"); } @Test - void targetRegionDifferentFromCurrentDeploymentIsRejected() { - ReflectionTestUtils.setField(service, "localRegion", "CN"); + void 目标区域与当前部署区域不一致时拒绝接收() { + EventInboxService cnService = service("CN"); EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - assertThatThrownBy(() -> service.receive(envelope)) + assertThatThrownBy(() -> cnService.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("目标区域"); } @@ -132,6 +132,21 @@ class EventInboxServiceTest { objectMapper.createObjectNode().put("orderId", "90001"), hash); } + private EventInboxService service(String region) { + EventInboxService service = new EventInboxService(); + ReflectionTestUtils.setField(service, "inboxStore", store); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "metrics", metrics); + ReflectionTestUtils.setField(service, "localRegion", region); + return service; + } + + private double receiveCount(String result) { + return registry.get(EventBridgeMetrics.RECEIVE_METRIC) + .tags("target_region", "OVERSEAS", "event_type", "OrderApproved", "result", result) + .counter().count(); + } + private EventEnvelope withHash(EventEnvelope source) { String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload())); return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), @@ -140,32 +155,22 @@ class EventInboxServiceTest { source.correlationId(), source.causationId(), source.payload(), hash); } - private EventEnvelope withTenant(EventEnvelope source, String tenantId) { - return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), - source.sourceRegion(), source.targetRegion(), tenantId, source.aggregateType(), - source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(), - source.correlationId(), source.causationId(), source.payload(), source.payloadHash()); - } - - private EventEnvelope withAggregateVersion(EventEnvelope source, Long aggregateVersion) { - return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), - source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(), - source.aggregateId(), aggregateVersion, source.handoverVersion(), source.traceId(), - source.correlationId(), source.causationId(), source.payload(), source.payloadHash()); - } - private BridgeInbox existing(EventEnvelope envelope, String hash) { BridgeInbox inbox = new BridgeInbox(); inbox.setEventId(envelope.eventId()); - inbox.setTenantId(envelope.tenantId()); inbox.setEventType(envelope.eventType()); inbox.setEventVersion(envelope.eventVersion()); inbox.setSourceRegion(envelope.sourceRegion().name()); inbox.setTargetRegion(envelope.targetRegion().name()); + inbox.setCustomerId(envelope.tenantId()); inbox.setAggregateType(envelope.aggregateType()); inbox.setAggregateId(envelope.aggregateId()); inbox.setAggregateVersion(envelope.aggregateVersion()); inbox.setHandoverVersion(envelope.handoverVersion()); + inbox.setOccurredAt(envelope.occurredAt()); + inbox.setTraceId(envelope.traceId()); + inbox.setCorrelationId(envelope.correlationId()); + inbox.setCausationId(envelope.causationId()); inbox.setPayloadHash(hash); inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0)); return inbox; diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java new file mode 100644 index 000000000..49c00c1c8 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java @@ -0,0 +1,71 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.apache.ibatis.annotations.Update; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class EventReplayServiceTest { + private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); + private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); + private final EventReplayService service = service(); + + @Test + void requeuesDeadEventWithAuditReason() { + when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1); + BridgeInbox event = new BridgeInbox(); + event.setEventId("event-1"); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS"); + event.setEventType(com.baiyue.common.event.EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); + when(mapper.selectById("event-1")).thenReturn(event); + when(mapper.nextAttemptNo("event-1")).thenReturn(4); + + service.replayDead("event-1", "operator-7", "incident-42 approved"); + + verify(mapper).replayDead(eq("event-1"), contains("operator-7")); + var captor = org.mockito.ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper).insert(captor.capture()); + assertThat(captor.getValue().getAttemptNo()).isEqualTo(4); + assertThat(captor.getValue().getStatus()).isEqualTo("MANUAL_REPLAY"); + assertThat(captor.getValue().getErrorMessage()).contains("operator-7", "incident-42"); + assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", "OVERSEAS", "event_type", event.getEventType(), + "result", "MANUAL_REPLAY").counter().count()).isEqualTo(1.0); + } + + @Test + void rejectsEventThatIsNotDead() { + when(mapper.replayDead(anyString(), anyString())).thenReturn(0); + + assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry")) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void replayOnlyRequeuesDeadStateAndDoesNotRewriteFrozenEvent() throws Exception { + String sql = String.join(" ", BridgeInboxMapper.class + .getMethod("replayDead", String.class, String.class) + .getAnnotation(Update.class).value()).replaceAll("\\s+", " "); + + assertThat(sql).contains("status='RETRY'", "retry_count=0", "status='DEAD'") + .doesNotContain("payload_json=", "payload_hash="); + } + + private EventReplayService service() { + EventReplayService service = new EventReplayService(); + ReflectionTestUtils.setField(service, "inboxMapper", mapper); + ReflectionTestUtils.setField(service, "attemptMapper", attemptMapper); + ReflectionTestUtils.setField(service, "metrics", metrics); + return service; + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java new file mode 100644 index 000000000..69ed8424a --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java @@ -0,0 +1,26 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpFirstlegEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void businessFailureIsDiagnosableAndMustNotBeTreatedAsAcknowledged() throws Exception { + var body = objectMapper.readTree(""" + {"code":"A0409","message":"事件摘要冲突","requestId":"request-cn-1","traceId":"trace-cn-1"} + """); + + assertThat(HttpFirstlegEventClient.failureMessage(200, body)) + .isEqualTo("头程消费失败: HTTP 200, code=A0409, message=事件摘要冲突, requestId=request-cn-1, traceId=trace-cn-1"); + } + + @Test + void nonJsonFailureIsStillDiagnosableWithoutRawBody() { + assertThat(HttpFirstlegEventClient.failureMessage(503, null)) + .isEqualTo("头程消费失败: HTTP 503, code=missing, message=non-json response, requestId=missing, traceId=missing"); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java new file mode 100644 index 000000000..b2de28029 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java @@ -0,0 +1,26 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpLastlegEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void failureIncludesBusinessCodeMessageAndTraceEvidence() throws Exception { + var body = objectMapper.readTree(""" + {"code":"A0400","message":"入仓计划数据无效","requestId":"request-1","traceId":"trace-1"} + """); + + assertThat(HttpLastlegEventClient.failureMessage(200, body)) + .isEqualTo("尾程消费失败: HTTP 200, code=A0400, message=入仓计划数据无效, requestId=request-1, traceId=trace-1"); + } + + @Test + void nonJsonFailureIsStillDiagnosableWithoutLeakingRawBody() { + assertThat(HttpLastlegEventClient.failureMessage(502, null)) + .isEqualTo("尾程消费失败: HTTP 502, code=missing, message=non-json response, requestId=missing, traceId=missing"); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java new file mode 100644 index 000000000..9a31b6db0 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java @@ -0,0 +1,44 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HttpSecurityProjectionEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void onlyMatchingLastlegAcknowledgementCompletesBridgeDelivery() throws Exception { + var acknowledged = objectMapper.readTree(""" + {"code":"200","data":{"eventId":"event-1","result":"APPLIED"}} + """); + var businessFailure = objectMapper.readTree(""" + {"code":"A0409","message":"payloadHash 冲突","data":{"eventId":"event-1"}} + """); + var wrongEvent = objectMapper.readTree(""" + {"code":"200","data":{"eventId":"event-2","result":"DUPLICATE"}} + """); + + assertThatCode(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, acknowledged, "event-1")).doesNotThrowAnyException(); + assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, businessFailure, "event-1")).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, wrongEvent, "event-1")).isInstanceOf(IllegalStateException.class); + } + + @Test + void defaultConsumerUrlTargetsLastlegSecurityEndpoint() throws Exception { + String application = Files.readString(Path.of("src/main/resources/application.yml")); + + assertThat(application).contains( + "LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events") + .doesNotContain("/internal/overseas-security/v1/events"); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java new file mode 100644 index 000000000..5224a11e5 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -0,0 +1,295 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventTypes; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import com.baiyue.common.web.CorrelationContext; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.slf4j.MDC; + +import java.net.URI; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OverseasEventDispatcherTest { + private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); + private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); + private final com.baiyue.eventbridge.service.LastlegEventClient eventClient = + mock(com.baiyue.eventbridge.service.LastlegEventClient.class); + private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient = + mock(com.baiyue.eventbridge.service.FirstlegEventClient.class); + private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class); + private OverseasEventDispatcher dispatcher; + private BridgeInbox event; + private SimpleMeterRegistry registry; + + @BeforeEach + void setUp() { + EventDispatcherProperties properties = new EventDispatcherProperties( + URI.create("https://lastleg.internal/events"), 50, 3, + Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1), + Duration.ofSeconds(3), Duration.ofSeconds(10)); + dispatcher = new OverseasEventDispatcher(); + registry = new SimpleMeterRegistry(); + ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); + ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); + ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); + ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); + ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient); + ReflectionTestUtils.setField(dispatcher, "properties", properties); + ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry)); + event = new BridgeInbox(); + event.setEventId("event-1"); + event.setEventType(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); + event.setSourceRegion("CN"); + event.setTargetRegion("OVERSEAS"); + event.setRetryCount(0); + event.setReceivedAt(LocalDateTime.now().minusSeconds(5)); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); + } + + @Test + void successfulDeliveryIsConfirmedAndAudited() throws Exception { + event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); + when(eventClient.deliver(event)).thenAnswer(invocation -> { + assertThat(MDC.get(CorrelationContext.TRACE_ID)) + .isEqualTo("550e8400e29b41d4a716446655440000"); + return "200"; + }); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markProcessed(eq("event-1"), anyString()); + verify(eventClient).deliver(event); + verifyNoInteractions(securityProjectionEventClient); + assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + assertThat(transportCount("DELIVERED")).isEqualTo(1.0); + assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1); + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); + } + + @Test + void failedDeliveryIsScheduledForRetry() throws Exception { + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"))) + .thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(5L), eq("network down")); + assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + assertThat(transportCount("RETRY")).isEqualTo(1.0); + } + + @Test + void finalFailureIsMovedToDeadState() throws Exception { + event.setRetryCount(2); + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down"))) + .thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(20L), eq("still down")); + assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD"); + assertThat(transportCount("DEAD")).isEqualTo(1.0); + } + + @Test + void exponentialBackoffIsCappedAtConfiguredMaximum() throws Exception { + event.setRetryCount(4); + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down"))) + .thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(60L), eq("still down")); + } + + @Test + void eventClaimedByAnotherInstanceIsSkipped() { + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class)); + } + + @Test + void cnTargetIsDeliveredToFirstleg() throws Exception { + event.setEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED); + event.setSourceRegion("OVERSEAS"); + event.setTargetRegion("CN"); + when(firstlegEventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(firstlegEventClient).deliver(event); + verifyNoInteractions(eventClient); + verifyNoInteractions(securityProjectionEventClient); + assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + } + + @Test + void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception { + for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED)) { + reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient, securityProjectionEventClient); + event.setEventType(eventType); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + when(securityProjectionEventClient.deliver(event)).thenReturn("200"); + + dispatcher.dispatchOne("event-1"); + + verify(securityProjectionEventClient).deliver(event); + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + } + } + + @Test + void unsupportedTargetRegionIsNeverMisroutedToLastleg() { + event.setTargetRegion("UNKNOWN"); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), contains("不支持"))) + .thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(securityProjectionEventClient); + assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + } + + @Test + void missingEventTypeFailsWithExplicitDiagnosticAndIsNeverDelivered() { + event.setEventType(null); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), + eq("事件类型不能为空,无法选择下游消费者"))).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(securityProjectionEventClient); + BridgeDeliveryAttempt attempt = updatedAttempt(); + assertThat(attempt.getStatus()).isEqualTo("RETRY"); + assertThat(attempt.getErrorMessage()).isEqualTo("事件类型不能为空,无法选择下游消费者"); + } + + @Test + void acknowledgedDeliveryWithLostLeaseIsNotRecordedAsRetryOrDead() throws Exception { + when(eventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper, never()).markFailed(anyString(), anyString(), anyInt(), anyLong(), anyString()); + BridgeDeliveryAttempt attempt = updatedAttempt(); + assertThat(attempt.getStatus()).isEqualTo("ACKNOWLEDGED_LEASE_LOST"); + assertThat(attempt.getResponseCode()).isEqualTo("200"); + assertThat(attempt.getErrorMessage()).contains("租约已失效"); + assertThat(transportCount("ACKNOWLEDGED_LEASE_LOST")).isEqualTo(1.0); + } + + @Test + void failedDeliveryAfterLeaseTakeoverDoesNotOverwriteNewOwnerState() throws Exception { + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"))) + .thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + assertThat(updatedAttempt().getStatus()).isEqualTo("FAILED_LEASE_LOST"); + assertThat(transportCount("FAILED_LEASE_LOST")).isEqualTo(1.0); + } + + @Test + void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception { + for (String target : List.of("OVERSEAS", "CN")) { + reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient); + event.setSourceRegion("CN".equals(target) ? "OVERSEAS" : "CN"); + event.setTargetRegion(target); + event.setRetryCount(0); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1, 2); + doAnswer(invocation -> { + event.setRetryCount(1); + event.setStatus("RETRY"); + event.setLastError(invocation.getArgument(4)); + event.setNextRetryAt(java.time.LocalDateTime.now().plusSeconds( + ((Number) invocation.getArgument(3)).longValue())); + return 1; + }).when(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), anyString()); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenAnswer(invocation -> { + event.setStatus("PROCESSED"); + event.setLastError(null); + event.setNextRetryAt(null); + return 1; + }); + if ("CN".equals(target)) { + when(firstlegEventClient.deliver(event)) + .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-cn traceId=trace-cn")) + .thenReturn("200"); + } else { + when(eventClient.deliver(event)) + .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-overseas traceId=trace-overseas")) + .thenReturn("200"); + } + + dispatcher.dispatchOne("event-1"); + assertThat(event.getStatus()).isEqualTo("RETRY"); + assertThat(event.getNextRetryAt()).isNotNull(); + assertThat(event.getLastError()).contains("HTTP 503", "requestId=", "traceId="); + dispatcher.dispatchOne("event-1"); + + ArgumentCaptor attempts = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper, times(2)).updateById(attempts.capture()); + assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getAttemptNo) + .containsExactly(1, 2); + assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getStatus) + .containsExactly("RETRY", "DELIVERED"); + assertThat(attempts.getAllValues().get(0).getErrorMessage()) + .contains("HTTP 503", "requestId=", "traceId="); + assertThat(event.getStatus()).isEqualTo("PROCESSED"); + assertThat(event.getLastError()).isNull(); + } + } + + private BridgeDeliveryAttempt updatedAttempt() { + ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper).updateById(captor.capture()); + return captor.getValue(); + } + + private double transportCount(String result) { + return registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", event.getTargetRegion(), + "event_type", event.getEventType(), "result", result) + .counter().count(); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java new file mode 100644 index 000000000..1dbf17567 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java @@ -0,0 +1,65 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.eventbridge.domain.BridgeInbox; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import java.net.URI; +import java.net.http.HttpRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeTracePropagationTest { + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void envelopeUuidFlowsThroughMdcAndRawHttpAsAChildSpan() { + BridgeInbox event = new BridgeInbox(); + event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); + event.setCorrelationId("ORDER:customer-99:booking-123"); + event.setEventId("event-business-id"); + + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + String localSpan = MDC.get(CorrelationContext.SPAN_ID); + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1/internal")) + .header(CorrelationContext.REQUEST_ID_HEADER, event.getEventId()) + .header(CorrelationContext.TRACE_ID_HEADER, event.getCorrelationId()) + .header(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + HttpRequest request = new EventBridgeHttpTracePropagation().apply(builder).GET().build(); + + assertThat(MDC.get(CorrelationContext.TRACE_ID)) + .isEqualTo("550e8400e29b41d4a716446655440000"); + assertThat(request.headers().firstValue(CorrelationContext.TRACE_ID_HEADER)) + .contains("550e8400e29b41d4a716446655440000"); + assertThat(request.headers().firstValue(CorrelationContext.REQUEST_ID_HEADER).orElseThrow()) + .matches("[0-9a-f-]{36}").isNotEqualTo(event.getEventId()); + assertThat(request.headers().firstValue(CorrelationContext.TRACE_PARENT_HEADER).orElseThrow()) + .matches("00-550e8400e29b41d4a716446655440000-[0-9a-f]{16}-01") + .doesNotContain(localSpan); + } + + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); + } + + @Test + void invalidLegacyEnvelopeTraceIsSafelyRebuiltAndPreviousMdcIsRestored() { + MDC.put("existing", "preserved"); + BridgeInbox event = new BridgeInbox(); + event.setTraceId("JWT-or-business-id-value"); + + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + assertThat(MDC.get(CorrelationContext.TRACE_ID)).matches("[0-9a-f]{32}") + .isNotEqualTo(event.getTraceId()); + } + + assertThat(MDC.get("existing")).isEqualTo("preserved"); + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); + } +} diff --git a/file/AGENTS.md b/file/AGENTS.md index 47a1deec8..1e746a882 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -12,7 +12,9 @@ | 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` | | 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` | | 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` | +| 平台订单商品图片读取 | `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`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | +| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | | 下游 OCR | `client/WeightOcrClient.java` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | @@ -22,6 +24,7 @@ - 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。 - 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。 +- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。 - 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。 - 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。 diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 96faadfc4..45ec8d2bd 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -20,9 +20,11 @@ public class InternalBusinessFileService { "CUSTOMS_RECEIPT", "FINANCE_VOUCHER", "CONTAINER_CLEARANCE_DOCUMENT", - "LOADING_CUSTOMS_DOCUMENT", + "LOADING_CUSTOMS_DECLARATION", "CONTAINER_DELIVERY_ORDER", - "CONTAINER_PICKUP_ORDER"); + "CONTAINER_PICKUP_ORDER", + "OVERSEAS_UNLOAD_PHOTO", + "OVERSEAS_RELABEL_PHOTO"); @Resource private FileObjectMapper files; @Resource private BusinessFileStorage storage; diff --git a/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java b/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java index 11fbd6567..964b44893 100644 --- a/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java +++ b/file/src/test/java/com/baiyue/file/client/ExportProviderFeignConfigurationTest.java @@ -9,9 +9,14 @@ import org.springframework.boot.autoconfigure.http.HttpMessageConverters; import java.nio.charset.StandardCharsets; import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; class ExportProviderFeignConfigurationTest { + @Test void keepsOpenFeignFormEncoderOnTheRuntimeClasspath() { + assertDoesNotThrow(() -> Class.forName("feign.form.spring.SpringFormEncoder")); + } + @Test void rejectsAnOversizedProviderResponseBeforeJsonDeserialization() { ExportProviderFeignConfiguration configuration=new ExportProviderFeignConfiguration(); Decoder decoder=configuration.exportProviderDecoder(HttpMessageConverters::new); diff --git a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java index 96c7bb064..04819c77e 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java @@ -35,14 +35,14 @@ class InternalBusinessFileControllerTest { } @Test void zeroTenantDownloadReturnsOwnedContentWithoutCaching() { - when(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L)) + when(service.download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L)) .thenReturn(new InternalBusinessFileService.Download("declaration.pdf", "application/pdf", new byte[]{1, 2})); - var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); assertThat(response.getBody()).containsExactly(1, 2); assertThat(response.getHeaders().getCacheControl()).contains("no-store"); - verify(service).download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); } @Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() { diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 44fd69118..b5a5100e1 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -40,25 +40,33 @@ class InternalBusinessFileServiceTest { when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation -> new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf", "application/pdf", file.getSize(), "b".repeat(64), "customs.pdf")); + when(storage.storeLoading(11L, 51L, file)).thenReturn(new BusinessFileStorage.Stored( + "business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg")); when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { ((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1; }); var clearance = service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file); - var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file); + var customs = service.upload(0L, "LOADING_CUSTOMS_DECLARATION", "51", 7L, file); var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file); var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file); + var unloadPhoto = service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file); assertThat(clearance).extracting(InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessType, InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41"); assertThat(customs).extracting(InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessType, - InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51"); + InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DECLARATION", "51"); assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER"); assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER"); + assertThat(unloadPhoto).extracting(InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51"); verify(storage, times(3)).storeLoading(0L, 41L, file); verify(storage).storeLoading(0L, 51L, file); + verify(storage).storeLoading(11L, 51L, file); } @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() { @@ -72,6 +80,22 @@ class InternalBusinessFileServiceTest { verifyNoInteractions(files, storage); } + @Test void overseasRelabelPhotoPersistsExactTaskOwnership() { + MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes()); + when(storage.storeLoading(100L, 81L, file)).thenReturn(new BusinessFileStorage.Stored( + "business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1; + }); + + var result = service.upload(100L, "OVERSEAS_RELABEL_PHOTO", "81", 7L, file); + + assertThat(result).extracting(InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81"); + } + @Test void unsupportedBusinessTypeFailsClosed() { assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L, new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()))) @@ -95,22 +119,23 @@ class InternalBusinessFileServiceTest { @Test void platformMetadataAndDownloadRequireExactCompositeOwnership() { FileObjectEntity value = new FileObjectEntity(); value.setId(91L); value.setTenantId(0L); - value.setBusinessType("LOADING_CUSTOMS_DOCUMENT"); value.setBusinessId("51"); + value.setBusinessType("LOADING_CUSTOMS_DECLARATION"); value.setBusinessId("51"); value.setOriginalName("declaration.pdf"); value.setObjectKey("business-files/0/51/declaration.pdf"); value.setContentType("application/pdf"); value.setSha256("c".repeat(64)); value.setSizeBytes(8L); when(files.selectOne(any())).thenReturn(value, value, null, null, null); when(storage.read(value.getObjectKey())).thenReturn("document".getBytes()); - assertThat(service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).fileName()) + assertThat(service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L).fileName()) .isEqualTo("declaration.pdf"); - assertThat(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).content()) + assertThat(service.download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L).content()) .isEqualTo("document".getBytes()); assertThatThrownBy(() -> service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "51", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); - assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "52", 91L)) + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "52", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); - assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 92L)) + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "51", 92L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); verify(storage).read(value.getObjectKey()); } + } diff --git a/finance/AGENTS.md b/finance/AGENTS.md index c9d1f04b2..eb5fbbf79 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -11,6 +11,7 @@ | 目标 | 首选入口 | |---|---| | 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` | +| 平台财务凭证上传 | `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` | | 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM | @@ -46,6 +47,7 @@ - 应付费用来源、成本登记和应付结算单引用的供应商必须通过 Warehouse 校验 ID 与 `ACTIVE` 状态;费用项目 `supplierCategory` 为空时允许任意启用供应商,非空时严格校验类别,不得从费用编码或名称猜测。名称快照只能取 Warehouse 返回值,不能接受自由文本替代。Warehouse 不可用、供应商停用或非空类别不匹配时必须失败关闭;修改该链路时同步检查事件消费、人工成本、结算创建/付款、Nacos 客户端地址和双方契约测试。 - 修改平台或客户 DTO 时同步检查 Admin `../../apps/admin-web/src/modules/finance/`、Client `../../apps/client-web/src/modules/finance/`、Gateway 路由、权限码和契约测试;客户响应不得暴露供应商成本、内部备注、平台凭证或其他客户数据。 - 修改 Finance 导出字段、筛选或 `exportType` 时,同步检查 `FinanceExportProviderService`、File `ExportDefinitionRegistry`/`ExportProviderClient`、Admin `modules/finance/` 与 `modules/downloads/`、导出权限和双方契约测试;页面只创建异步下载任务,不绕过 File 导出中心直接拼装业务文件。 +- Finance 费用池必须保留上游 `direction`、客户/供应商名称和付款证据快照;Firstleg 的 `DIRECT_PAID` 校验仍归 Firstleg。Finance 上传端只创建受客户范围约束的 `FINANCE_VOUCHER` 文件,不替代上游费用审核与付款快照校验。 ## 验证 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index bb1b71671..a6ff072d0 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责履约任务/分配、入库单、测量/OCR、审核后初始计价、FBA 计费、客户在途柜量统计及国内出仓履约链路。 +- 负责履约任务/分配、入库单、测量/OCR、审核后初始计价、FBA 计费、客户在途柜量统计、国内出仓履约链路,以及四期国内出仓后的船节点查询任务和节点事实。 - 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。 - 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。 @@ -10,6 +10,7 @@ | 目标 | 入口 | |---|---| +| 内部模块化基线与依赖分析 | `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` | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | | 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 | @@ -18,19 +19,25 @@ | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` | | 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | +| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` | | 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` | | 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | | 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | +| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` | | 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | | 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | | 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` | -| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`loading/` | +| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 | +| 货柜资料、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` | -| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` | -| 海运订舱与船期 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformCarrierScheduleQueryController.java`、`PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `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` | +| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/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` | +| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | -| 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | +| 拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;按真实 `containerBookingId` 查询聚合和 `gate`、确认离仓,契约 `src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | | 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | @@ -40,7 +47,9 @@ ## 修改约束 +- 移动包、拆分端口/适配器或引入新的跨切片依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-firstleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 - 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。 +- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。 - 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 - 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。 - 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 @@ -48,7 +57,13 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 +- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 +- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,国内出口报关资料必须存在且已完成,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 +- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 +- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 +- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。 ## 三期装柜与 PDA 资源结算 diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md new file mode 100644 index 000000000..df1b0fc9e --- /dev/null +++ b/firstleg/docs/firstleg-internal-modularization-baseline.md @@ -0,0 +1,250 @@ +# Firstleg 内部模块化基线与切片实施记录 + +## 1. 范围与生成方式 + +本基线只分析 `firstleg` 同一部署单元,不拆微服务,不改变业务流程、数据库结构、OpenAPI 或跨服务契约。分析输入为: + +- 首切片实施前 `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。 +- 首切片实施前 `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。 +- 首切片实施后 `src/main/java`:430 个 Java 文件;949 条可解析的模块内类依赖;67 个物理包。增加项仅为只读 Out Port。 + +可重复命令: + +```powershell +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set all --format json +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format dot +``` + +分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出物理包图、逻辑切片图、SCC、跨切片类依赖、Entity/DTO 泄漏和 Mapper 泄漏。Spring 字符串扫描路径、反射、MyBatis SQL 中的表引用不属于 Java 类型图,因此在本报告单独标注。 + +## 2. 当前 SCC + +### 2.1 主源码物理包 SCC + +当前只有一个多节点物理包 SCC: + +```text +com.baiyue.firstleg.mapper + <-> com.baiyue.firstleg.repository +``` + +直接证据是全局 Repository 大量依赖全局 Mapper,同时 `InboundAllocationQueryMapper` 反向引用 `repository.PriceBookQueryRows`。这说明全局数据访问层尚不能按传统横向层直接单向化。 + +合并测试源码后,测试对生产类型的依赖把 SCC 扩大为下列十包;这不是生产运行时环,但表示测试也直接绑定了横向实现包: + +```text +controller -> loading.repository -> loading.service -> mapper +-> oceanbooking.controller -> oceanbooking.repository -> oceanbooking.service +-> pricing -> repository -> service -> controller +``` + +### 2.2 逻辑切片 SCC + +逻辑切片图存在一个八节点 SCC: + +```text +booking-customs-trucking +finance-integration +inbound +measurement-pricing +outbound +shared-technical +shipping-nodes +split-loading +``` + +`customer-statistics` 不在 SCC 内。当前 36 条切片间边均冻结在 `src/test/resources/architecture/firstleg-slice-dependencies.json`;合同允许删除现有边,不允许增加新边。 + +主要双向依赖包括: + +| 双向关系 | 直接证据 | +|---|---| +| 入库 ↔ 测量计价 | 入库编排直接使用测量/费用 Entity、Mapper;计价与客户费用查询直接使用入库单、状态和 Outbox | +| 入库 ↔ 分柜装柜 | 统一入库读模型读取装柜/PDA Mapper 与 Entity;装柜结算和事件发布读取入库状态、托盘与 Outbox | +| 分柜装柜 ↔ 出仓 | 货柜聚合读出仓历史;海外卸柜回写反向更新货柜/装柜事实 | +| 订舱报关拖车 ↔ 出仓 | 出仓门禁聚合报关/船期贡献者;报关和船期贡献者反向依赖出仓 DTO | +| 订舱报关拖车 ↔ 分柜装柜 | 订舱货柜分配使用装柜实体;报关直接读取装柜计划与明细 Mapper | +| 订舱报关拖车 ↔ 测量计价 | 价格簿校验影响订舱;供应商/渠道校验又由全局计价服务消费 | + +## 3. 八个业务切片现状映射 + +类数量和跨切片依赖数量以主源码图为准;“出/入”均为类级直接依赖条数。 + +| 业务切片 | 当前物理位置与识别范围 | 类数 | 出/入 | 主要边界问题 | +|---|---|---:|---:|---| +| 入库 | 全局 `controller/service/repository/mapper/entity/dto` 中 `Inbound*`、`Fulfillment*`、`Assignment*`、`UnifiedInbound*` | 67 | 26/60 | 被计价和装柜大量反向读取;`InboundWorkflowRepository` 直接持有测量/费用 Mapper | +| 测量计价 | 全局层中的 `Measurement*`、`Mojia*`、`Pricing*`、`PriceBook*`、`Fba*`、`Dimension*`、`FeeItem*`、`InboundFee*`,以及 `pricing/` | 113 | 72/25 | 最大切片;对入库有 35 条直接依赖,其中 20 条为跨切片数据模型泄漏、6 条为 Mapper 泄漏 | +| 分柜装柜 | `split/`、`loading/` 加全局 `Loading*`、`SmartLoading*`、货柜与 PDA 类 | 82 | 50/32 | 同时依赖入库、订舱、出仓和共享层;全局 Repository 直接读取 `loading/split` Mapper/Entity | +| 订舱报关拖车 | `oceanbooking/`、`customs/`,全局 `TruckAppointment*`、`WarehouseSupplier*`、出仓门禁贡献者 | 68 | 25/13 | `CustomsRepository` 直接读取入库与装柜 Mapper;订舱 Repository 直接读取全局货柜 Entity | +| 出仓 | `outbound/dispatch/` 与全局 `OutboundDispatch*`、运输历史、海外卸柜回写 | 17 | 8/10 | 与装柜、订舱门禁双向;海外回写直接使用全局货柜 Mapper/Entity | +| 船节点 | `portschedule/` 中 `CarrierScheduleQuery/Node/Adapter/Attempt/Exception*` | 22 | 8/1 | 查询服务直接读取订舱、船期、货柜 Entity;节点 Outbox 直接使用入库 Outbox Mapper/Entity | +| 客户统计 | `customerstatistics/adapter`、`customerstatistics/application` | 5 | 1/0 | 已完成首切片;唯一跨切片依赖为 Web Adapter 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 | +| 财务集成 | 全局 `InternalFinance*`、`FinanceFeeSource*`、`FinanceCostSnapshot*`、付款凭证/快照类 | 20 | 17/6 | 直接读取入库费、柜次费、装柜及共享 Outbox 的 Entity/Mapper;继续受财务总控冻结的业务口径约束 | + +共享技术层当前有 36 类,包含应用启动/配置、Scope Guard、单据号、部分跨服务 Client 及无法安全归属的全局模型。跨服务适配器按 `client` 包独立标识,不能因逻辑切片归属而被迁入应用核心。 + +## 4. 泄漏与逆向依赖基线 + +主源码共有 88 条跨切片 Entity/DTO 泄漏、31 条跨切片 Mapper 泄漏。高频项如下: + +| 来源 → 目标 | Entity/DTO | Mapper | 代表性证据 | +|---|---:|---:|---| +| 测量计价 → 入库 | 20 | 6 | `FbaPricingRepository -> InboundOutboxEntity/Mapper`;客户费用查询直接使用 `InboundOrderDetail` | +| 分柜装柜 → 入库 | 7 | 6 | `LoadingDeliveryService -> InboundStatusHistoryEntity/Mapper`;装柜 Outbox 复用入库 Outbox | +| 入库 → 测量计价 | 4 | 4 | `InboundWorkflowRepository -> InboundFee/Measurement/Review Mapper` | +| 入库 → 分柜装柜 | 4 | 3 | `UnifiedInboundReadRepository` 直接读取装柜证据、PDA 任务、预装计划 Mapper/Entity | +| 财务集成 → 入库 | 4 | 2 | 财务来源 Outbox 直接复用 `InboundOutboxEntity/Mapper` | +| 订舱报关拖车 → 分柜装柜 | 3 | 3 | `CustomsRepository -> LoadingPlan/LoadingPlanItem Mapper` | +| 分柜装柜 → 订舱报关拖车 | 3 | 0 | `ContainerService -> OceanBookingContainer*Entity` | +| 船节点 → 入库 | 1 | 1 | `CarrierScheduleNodeOutboxService -> InboundOutboxEntity/Mapper` | + +逆向依赖的判定不是简单的 `controller -> service -> repository` 层次关系,而是“业务核心直接依赖另一切片的持久化实现或 Entity”。首轮治理应优先用查询/命令 Port 替代这些直接引用,不共享领域 Entity。 + +## 5. 首切片选择:客户统计 + +选择 `customer-statistics`,不是因为预设,而是因为以下实际证据: + +1. 实施前仅 4 个生产类且已集中在单一顶层包;实施后为原 4 类加 1 个只读 Out Port,没有业务切片对其产生 Java 类型入向依赖。 +2. 唯一跨切片 Java 依赖为 Web Controller 对共享 `FirstlegScopeGuard` 的调用,不涉及 Entity 或 Mapper 泄漏。 +3. 不属于当前八节点 SCC,也没有跨服务 Feign/HTTP Client。 +4. 现有调用面稳定:一个平台 GET 接口、一个 Admin API 消费者、两个后端测试。 +5. 它仍包含一个明确的内部逆向依赖:应用 Service 直接依赖 MyBatis Mapper,适合用最小 Out Port 证明目标依赖方向。 + +限制:`CustomerTransitStatisticsMapper` 的 SQL 只读 `fl_ocean_booking`、`fl_container_booking`、`fl_loading_plan`、`fl_loading_plan_item`。首切片只隔离 Java 依赖,不宣称拥有这些表,也不改变 SQL、索引或统计口径。 + +## 6. 首切片目标目录与允许依赖 + +```text +customerstatistics/ + adapter/in/web/ + PlatformCustomerTransitStatisticsController.java + application/ + CustomerTransitStatisticsQueryService.java + model/CustomerTransitStatisticsModels.java + port/out/CustomerTransitStatisticsReader.java + adapter/out/persistence/ + CustomerTransitStatisticsMapper.java +``` + +允许方向: + +```text +adapter.in.web -> application +adapter.out.persistence -> application.port.out +application -> application.model + common-core +``` + +额外允许项仅限 Web 适配器使用 `common-security`、Spring Web 和现有 `FirstlegScopeGuard`。禁止 `application` 依赖 MyBatis、Spring MVC、全局 `entity/mapper/repository/service` 或其他业务切片。 + +## 7. 精确迁移清单、调用方与测试 + +首切片已按以下清单迁移/新增: + +| 当前文件 | 目标/动作 | +|---|---| +| `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java` | 移到 `adapter/in/web/`,URI、权限和响应保持不变 | +| `customerstatistics/dto/CustomerTransitStatisticsDtos.java` | 移到 `application/model/`,保持 JSON 字段与数值语义不变 | +| `customerstatistics/service/CustomerTransitStatisticsService.java` | 移到 `application/` 并改名为 Query Service,只依赖 Out Port | +| `customerstatistics/mapper/CustomerTransitStatisticsMapper.java` | 移到 `adapter/out/persistence/`,实现/扩展只读 Out Port;SQL 原样保留 | +| 新增 `customerstatistics/application/port/out/CustomerTransitStatisticsReader.java` | 定义 `summarize(customerId)` 与只读投影,不暴露 MyBatis 类型 | +| `src/test/java/.../customerstatistics/CustomerTransitStatisticsServiceTest.java` | Mock Out Port,不再 Mock Mapper | +| `src/test/java/.../customerstatistics/CustomerTransitStatisticsContractTest.java` | 更新包引用,继续锁定 URI、权限、SQL、OpenAPI 与索引语义 | +| `BaiyueFirstlegApplication.java`、`BaiyueFirstlegApplicationContractTest.java` | 只有 Mapper 扫描路径因移动确需调整时修改;不改变其他扫描包 | + +外部调用方只读核对,不在首切片修改: + +- `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` +- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts` +- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.test.ts` + +## 8. 回滚边界 + +- 不修改表、SQL 内容、接口路径、权限码、响应字段、统计定义、Nacos、部署或跨服务契约。 +- 回滚只涉及上述客户统计 Java 包移动、Out Port 和对应测试;可整体恢复到现有四类结构。 +- 若 Mapper 继承 Out Port 导致 MyBatis 代理行为或扫描合同失败,停止实施并恢复包内变更,不以增加全局扫描、共享 POM 依赖或兼容 Bean 绕过。 +- 首切片完成前不开始第二切片。 + +## 9. 当前只读合同与后续协作 + +本轮已增加模块内、无第三方依赖的只读合同: + +- `scripts/analyze-firstleg-dependencies.mjs`:生成 JSON/Markdown/DOT 和 Tarjan SCC。 +- `src/test/resources/architecture/firstleg-slice-dependencies.json`:冻结当前切片边;历史边可减少,不可增加。 +- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计目标 5 类/目录、唯一出向边、零入向边、不进入 SCC 和 application 依赖禁令。 + +与其他架构专项的依赖: + +- 专项一(拓扑与质量门禁):决定是否引入 ArchUnit、如何把本合同接入 Maven/CI、以及共享 POM 和全局质量门禁。当前未修改 POM;建议由专项一把本脚本作为 Firstleg 局部门禁接入,再以 ArchUnit 替换/增强源码级解析。 +- 专项二(同步依赖与失败传播):在为其他切片定义应用 Port 前提供 Feign/HTTP 调用图和失败语义。客户统计当前没有跨服务 Client,因此首切片无需等待专项二;后续入库、财务集成、订舱和装柜切片必须协调。 + +## 10. 最小验证命令 + +```powershell +node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown +mvn -pl firstleg -am test-compile +mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransitStatisticsContractTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +首切片已经发生 Java 包与依赖签名变化,三条命令均为直接门禁;另需执行 `mvn -pl firstleg -am test-compile` 覆盖全部测试源码调用点。 + +## 11. 首切片实施结果 + +- 四个旧包类已删除,没有转发兼容层;目标五层目录已由 Node 合同锁定。 +- `CustomerTransitStatisticsReader.SummaryProjection` 只包含 `customerId`、`containerCount`、`totalTeu`、`unknownContainerCount`、`asOf`,不暴露 MyBatis、Entity 或其他切片类型。 +- 依赖方向由 `application Service -> MyBatis Mapper` 改为 `application Service -> application Port <- persistence Mapper`。 +- 主源码由 429 类/948 条内部依赖/66 包变为 430 类/949 条内部依赖/67 包;36 条切片间边、88 条模型泄漏、31 条 Mapper 泄漏及全局 `mapper <-> repository` SCC 均未增加。客户统计仍只有 `adapter.in.web -> FirstlegScopeGuard` 一条跨切片边,且没有入向边。 +- 根 Mapper 扫描只把旧精确包替换为新精确包,没有扩大扫描范围或增加兼容 Bean。 +- 验证结果:Node 合同 3/3;`test-compile` 成功;客户统计加根扫描定向测试 7/7;`mvn -pl firstleg -am test` 共 854 个测试,0 失败、0 错误、3 跳过,构建成功。 +- 首次并发执行 `test-compile` 时,其他专项同时写入共享 `target/classes` 导致若干无关 class 文件瞬时缺失;源符号核对正常,独立重跑 `test-compile` 与完整测试均成功。 + +## 12. 第二切片:价格簿只读查询 + +### 12.1 选择证据 + +第二切片选择 `pricebook-query`,而没有选择报关、拖车、出仓或船节点: + +- 原实现只有 `PriceBookQueryRows`、`PriceBookQueryRepository`、`PriceBookQueryMapper` 三个核心文件,全部为只读查询,不承载 Controller、权限或状态机。 +- `PriceBookQueryMapper -> repository.PriceBookQueryRows` 是主源码唯一 `mapper -> repository` 反向依赖,也是物理包 SCC `mapper <-> repository` 的唯一成因。 +- 报关和拖车均涉及 MultipartFile、Feign、装柜或出仓门禁;出仓和船节点属于当前业务热点,风险高于只读价格簿查询。 +- 本切片不触碰 `LoadingPickService`、客户尺寸初始化、IAM 解环、POM、CI、AGENTS、SQL 或部署配置。 + +### 12.2 目标结构与依赖方向 + +```text +pricebookquery/ + application/ + PriceBookQueryService.java + model/PriceBookQueryRows.java + port/in/PriceBookQueryUseCase.java + port/out/PriceBookQueryReader.java + adapter/out/persistence/ + PriceBookQueryMapper.java +``` + +依赖由“多个业务 Service -> 全局 Repository -> 全局 Mapper -> Repository Rows”改为: + +```text +业务消费者 -> PriceBookQueryUseCase +PriceBookQueryService -> PriceBookQueryReader +PriceBookQueryMapper -> PriceBookQueryReader + PriceBookQueryRows +``` + +消费者只能访问 In Port 和只读模型;application 不依赖 Spring MVC、MyBatis、MyBatis-Plus 或其他 Firstleg 切片。Mapper 的七段 `@Select` SQL 文本和方法参数保持不变。 + +### 12.3 依赖图变化 + +- 主源码由 430 类/949 条内部依赖/67 包变为 432 类/953 条内部依赖/72 包;新增项为 In Port、Out Port 和 Application Service,原 Repository 被删除。 +- 物理包多节点 SCC 从 1 个降为 0 个。 +- `pricebook-query` 是零出向边的叶子,不进入逻辑 SCC;显式入向仅为订舱、测量计价、分柜装柜三个消费者切片。 +- 原 `split-loading -> measurement-pricing` 两条价格簿引用被替换为显式 `split-loading -> pricebook-query`;Node 合同没有简单放宽旧边。 +- 既有 88 条跨切片 Entity/DTO 泄漏和 31 条 Mapper 泄漏均未增加。 + +### 12.4 棘轮与回滚边界 + +- Node 合同锁定五个目标类位置、application 依赖禁令、零出向/三类入向和消费者只能访问 In Port/模型,并断言物理包 SCC 为空。 +- `PriceBookQueryArchitectureTest` 用 ArchUnit 执行同等边界,持久化 Mapper 必须实现 Out Port。 +- 回滚仅恢复原三文件及消费者类型引用;不涉及 URI、权限、JSON、SQL、表结构、业务状态或环境配置。 +- 只读模型仍保留原 Lombok 可变 Bean 形态以维持 MyBatis 映射行为;本轮不顺带改造成不可变领域对象。 +- 验证结果:七段 `@Select` SQL 文本逐块完全一致;Node 合同 5/5;`test-compile` 成功;价格簿、ArchUnit、根扫描及三个消费者切片定向测试 78/78;Firstleg 聚合全量 878 个测试,0 失败、0 错误、3 跳过,构建成功。 diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 02098cbe8..1a35216ef 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -23,6 +23,7 @@ org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest + com.tngtech.archunitarchunittest org.projectlomboklomboktrue org.springframework.bootspring-boot-maven-plugin diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs new file mode 100644 index 000000000..302d466c7 --- /dev/null +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -0,0 +1,330 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const sourceRoots = { + main: path.join(moduleRoot, 'src', 'main', 'java'), + test: path.join(moduleRoot, 'src', 'test', 'java'), +}; +const internalPrefix = 'com.baiyue.firstleg.'; + +const sliceRules = [ + ['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/], + ['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/], + ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], + ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], + ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], + ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/], + ['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/], + ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/], + ['inbound', /(?:Inbound|Fulfillment|Assignment|UnifiedInbound|CartonLabel)/], +]; + +function parseArgs(argv) { + const options = { sourceSet: 'main', format: 'json', output: null, check: null }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--source-set') options.sourceSet = argv[++index]; + else if (argument === '--format') options.format = argv[++index]; + else if (argument === '--output') options.output = argv[++index]; + else if (argument === '--check') options.check = argv[++index]; + else if (argument === '--help') { + process.stdout.write('Usage: node scripts/analyze-firstleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n'); + process.exit(0); + } else throw new Error(`Unknown argument: ${argument}`); + } + if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`); + if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`); + return options; +} + +function walkJavaFiles(root) { + if (!fs.existsSync(root)) return []; + const result = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(absolute); + else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute); + } + } + return result.sort(); +} + +function stripCommentsAndStrings(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/\/\/[^\r\n]*/g, ' ') + .replace(/"(?:\\.|[^"\\])*"/g, '""') + .replace(/'(?:\\.|[^'\\])*'/g, "''"); +} + +function classifySlice(fqcn) { + for (const [slice, expression] of sliceRules) { + if (expression.test(fqcn)) return slice; + } + return 'shared-technical'; +} + +function classifyKind(fqcn) { + const packageName = fqcn.slice(0, fqcn.lastIndexOf('.')); + if (packageName.includes('.client')) return 'cross-service-adapter'; + if (/\.(adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; + if (/\.(entity|dto)(?:\.|$)/.test(packageName)) return 'data-model'; + return 'business-application'; +} + +function parseSource(file, sourceSet) { + const raw = fs.readFileSync(file, 'utf8'); + const source = stripCommentsAndStrings(raw); + const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1]; + const typeName = path.basename(file, '.java'); + if (!packageName) throw new Error(`Missing package declaration: ${file}`); + const fqcn = `${packageName}.${typeName}`; + const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]); + return { + file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'), + sourceSet, + packageName, + typeName, + fqcn, + imports, + source, + slice: classifySlice(fqcn), + kind: classifyKind(fqcn), + }; +} + +function collectSources(sourceSet) { + const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet]; + return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set))); +} + +function resolveDependencies(sources) { + const knownTypes = new Map(sources.map(source => [source.fqcn, source])); + const typesByPackage = new Map(); + for (const source of sources) { + if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []); + typesByPackage.get(source.packageName).push(source); + } + const dependencies = []; + const seen = new Set(); + for (const source of sources) { + const candidates = new Set(); + for (const imported of source.imports) { + if (knownTypes.has(imported)) candidates.add(imported); + else if (imported.startsWith(internalPrefix)) { + const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`)); + if (owner) candidates.add(owner); + } + } + for (const samePackageType of typesByPackage.get(source.packageName) ?? []) { + if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) { + candidates.add(samePackageType.fqcn); + } + } + for (const targetFqcn of candidates) { + if (targetFqcn === source.fqcn) continue; + const key = `${source.fqcn}->${targetFqcn}`; + if (seen.has(key)) continue; + seen.add(key); + dependencies.push({ source: source.fqcn, target: targetFqcn }); + } + } + return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function aggregateEdges(dependencies, nodeOf) { + const counts = new Map(); + for (const dependency of dependencies) { + const source = nodeOf(dependency.source); + const target = nodeOf(dependency.target); + if (source === target) continue; + const key = `${source}\0${target}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts].map(([key, count]) => { + const [source, target] = key.split('\0'); + return { source, target, count }; + }).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function stronglyConnectedComponents(nodes, edges) { + const adjacency = new Map(nodes.map(node => [node, []])); + for (const edge of edges) adjacency.get(edge.source)?.push(edge.target); + const indexByNode = new Map(); + const lowLink = new Map(); + const stack = []; + const onStack = new Set(); + const components = []; + let nextIndex = 0; + function visit(node) { + indexByNode.set(node, nextIndex); + lowLink.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + for (const target of adjacency.get(node) ?? []) { + if (!indexByNode.has(target)) { + visit(target); + lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target))); + } else if (onStack.has(target)) { + lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target))); + } + } + if (lowLink.get(node) === indexByNode.get(node)) { + const component = []; + let current; + do { + current = stack.pop(); + onStack.delete(current); + component.push(current); + } while (current !== node); + components.push(component.sort()); + } + } + for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node); + return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0])); +} + +function buildReport(sourceSet) { + const sources = collectSources(sourceSet); + const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source])); + const dependencies = resolveDependencies(sources); + const packages = [...new Set(sources.map(source => source.packageName))].sort(); + const slices = [...new Set(sources.map(source => source.slice))].sort(); + const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName); + const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice); + const packageSccs = stronglyConnectedComponents(packages, packageEdges); + const sliceSccs = stronglyConnectedComponents(slices, sliceEdges); + const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice); + const modelLeaks = directCrossSliceDependencies.filter(edge => ['data-model'].includes(sourceByFqcn.get(edge.target).kind)); + const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName)); + return { + schemaVersion: 1, + generatedFrom: sourceSet, + moduleRoot: 'firstleg', + summary: { + javaFiles: sources.length, + internalClassDependencies: dependencies.length, + packages: packages.length, + slices: slices.length, + crossSliceClassDependencies: directCrossSliceDependencies.length, + crossSliceModelLeaks: modelLeaks.length, + crossSliceMapperLeaks: mapperLeaks.length, + }, + sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])), + classes: sources.map(({ source, ...entry }) => entry), + classDependencies: dependencies, + packageEdges, + sliceEdges, + packageSccs, + sliceSccs, + directCrossSliceDependencies, + modelLeaks, + mapperLeaks, + }; +} + +function renderMarkdown(report) { + const lines = [ + '# Firstleg internal dependency report', '', + `- Source set: ${report.generatedFrom}`, + `- Java files: ${report.summary.javaFiles}`, + `- Internal class dependencies: ${report.summary.internalClassDependencies}`, + `- Package SCCs (>1 node): ${report.packageSccs.length}`, + `- Slice SCCs (>1 node): ${report.sliceSccs.length}`, + '', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|', + ...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`), + '', '## Slice SCCs', '', + ...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Package SCCs', '', + ...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Cross-slice model and mapper leaks', '', + ...[...report.modelLeaks, ...report.mapperLeaks] + .filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index) + .map(edge => `- \`${edge.source}\` -> \`${edge.target}\``), + '', + ]; + return lines.join('\n'); +} + +function renderDot(report) { + const lines = ['digraph firstleg_slices {', ' rankdir=LR;']; + for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); + lines.push('}', ''); + return lines.join('\n'); +} + +function checkContract(report, contractPath) { + const absolute = path.resolve(process.cwd(), contractPath); + const contract = JSON.parse(fs.readFileSync(absolute, 'utf8')); + const allowed = new Set(contract.allowedSliceEdges ?? []); + const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); + const additions = actual.filter(edge => !allowed.has(edge)); + const violations = additions.map(edge => `new slice dependency: ${edge}`); + const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); + for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { + const actualClass = classesByName.get(fqcn); + if (!actualClass) violations.push(`required class missing: ${fqcn}`); + else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); + } + const applicationRules = contract.applicationDependencyRules + ?? (contract.applicationDependencyRule ? [contract.applicationDependencyRule] : []); + for (const applicationRule of applicationRules) { + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix)); + for (const entry of applicationClasses) { + for (const imported of entry.imports) { + if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) { + violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`); + } + } + } + for (const edge of report.classDependencies) { + if (edge.source.startsWith(applicationRule.packagePrefix) + && edge.target.startsWith(internalPrefix) + && !edge.target.startsWith(applicationRule.allowedInternalPrefix)) { + violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`); + } + } + } + for (const exposureRule of contract.sliceExposureRules ?? []) { + for (const edge of report.classDependencies) { + if (!edge.source.startsWith(exposureRule.slicePrefix) + && edge.target.startsWith(exposureRule.slicePrefix) + && !(exposureRule.allowedTargetPrefixes ?? []).some(prefix => edge.target.startsWith(prefix))) { + violations.push(`forbidden slice implementation dependency: ${edge.source} -> ${edge.target}`); + } + } + } + for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { + const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); + const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); + if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) { + violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`); + } + if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) { + violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`); + } + } + if (violations.length > 0) { + process.stderr.write(`Firstleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`); + process.exitCode = 1; + } +} + +const options = parseArgs(process.argv.slice(2)); +const report = buildReport(options.sourceSet); +if (options.check) checkContract(report, options.check); +const rendered = options.format === 'json' + ? `${JSON.stringify(report, null, 2)}\n` + : options.format === 'markdown' + ? renderMarkdown(report) + : renderDot(report); +if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); +else process.stdout.write(rendered); diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs new file mode 100644 index 000000000..101c2f807 --- /dev/null +++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const analyzer = path.join(moduleRoot, 'scripts', 'analyze-firstleg-dependencies.mjs'); +const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'firstleg-slice-dependencies.json'); + +function analyze() { + return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'json'], { + cwd: moduleRoot, + encoding: 'utf8', + })); +} + +test('current source does not add slice dependencies beyond the baseline', () => { + execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], { + cwd: moduleRoot, + encoding: 'utf8', + }); +}); + +test('customer statistics remains an independently packaged low-coupling slice', () => { + const report = analyze(); + const classes = report.classes.filter(entry => entry.slice === 'customer-statistics'); + assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController', + 'com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper', + 'com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService', + 'com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos', + 'com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader', + ]); + const outgoing = report.sliceEdges.filter(edge => edge.source === 'customer-statistics'); + const incoming = report.sliceEdges.filter(edge => edge.target === 'customer-statistics'); + assert.deepEqual(outgoing, [{ source: 'customer-statistics', target: 'shared-technical', count: 1 }]); + assert.deepEqual(incoming, []); + assert.equal(report.sliceSccs.some(component => component.includes('customer-statistics')), false); +}); + +test('customer statistics application layer only depends inward', () => { + const report = analyze(); + const prefix = 'com.baiyue.firstleg.customerstatistics.application'; + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); + assert.equal(applicationClasses.length, 3); + for (const entry of applicationClasses) { + assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false); + } + const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix)); + assert.deepEqual(outwardDependencies, []); +}); + +test('price book query is a leaf slice and removes the package cycle', () => { + const report = analyze(); + const classes = report.classes.filter(entry => entry.slice === 'pricebook-query'); + assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper', + 'com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService', + 'com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows', + 'com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase', + 'com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader', + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'pricebook-query'), []); + assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'pricebook-query') + .map(edge => edge.source).sort(), ['booking-customs-trucking', 'measurement-pricing', 'split-loading']); + assert.equal(report.sliceSccs.some(component => component.includes('pricebook-query')), false); + assert.deepEqual(report.packageSccs, []); +}); + +test('price book query application and consumers respect ports', () => { + const report = analyze(); + const prefix = 'com.baiyue.firstleg.pricebookquery.application'; + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); + assert.equal(applicationClasses.length, 4); + for (const entry of applicationClasses) { + assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false); + } + const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix)); + assert.deepEqual(outwardDependencies, []); + const allowedTargets = [ + 'com.baiyue.firstleg.pricebookquery.application.model.', + 'com.baiyue.firstleg.pricebookquery.application.port.in.', + ]; + const implementationLeaks = report.classDependencies.filter(edge => !edge.source.startsWith('com.baiyue.firstleg.pricebookquery.') + && edge.target.startsWith('com.baiyue.firstleg.pricebookquery.') + && !allowedTargets.some(prefixValue => edge.target.startsWith(prefixValue))); + assert.deepEqual(implementationLeaks, []); +}); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index b9ed1c6ee..595ec1411 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -18,7 +18,8 @@ import org.springframework.scheduling.annotation.EnableScheduling; "com.baiyue.firstleg.outbound.dispatch.mapper", "com.baiyue.firstleg.portschedule.mapper", "com.baiyue.firstleg.customs.mapper", - "com.baiyue.firstleg.customerstatistics.mapper" + "com.baiyue.firstleg.customerstatistics.adapter.out.persistence", + "com.baiyue.firstleg.pricebookquery.adapter.out.persistence" }) @EnableFeignClients(basePackages = "com.baiyue.firstleg") @EnableScheduling diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java new file mode 100644 index 000000000..7e3d34a12 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.client; + +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; + +import java.time.LocalDateTime; +import java.util.List; + +@FeignClient(name = "integration-service", contextId = "carrierScheduleIntegrationClient", + url = "${baiyue.integration-service.url:}") +public interface CarrierScheduleIntegrationClient { + @PostMapping("/internal/integrations/carrier-schedules/query") + Envelope query(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody QueryRequest request); + + record Envelope(String code, String message, T data) { + public boolean successful() { return "200".equals(code); } + } + record QueryRequest(String carrierCode, String trackingType, String trackingNo) { } + record QueryResult(String providerCode, String outcome, List observations, + List exceptions, + String errorCode, String errorMessage, LocalDateTime providerObservedAt) { } + record Observation(String providerEventId, String providerNodeCode, String standardNode, + LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, + String locationCode, String locationName, String sourceReference, + String rawPayloadHash) { } + record ExceptionObservation(String providerEventId, String providerStatus, String exceptionCode, + boolean active, LocalDateTime observedAt, String locationCode, + String locationName, String sourceReference, String rawPayloadHash) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java index 491896aea..f48074f63 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java @@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; /** 将本地业务 Outbox 投递到跨区事件桥。 */ -@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient") +@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient", url = "${baiyue.lastleg.event-bridge-url:}") public interface EventBridgeClient { @PostMapping("/internal/event-bridge/events") ApiResponse receive( 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 651269bd1..4e299343f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -59,6 +59,9 @@ public class InternalInboundOrderController { || command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank() || command.inboundCode() == null || command.inboundCode().isBlank() || command.inboundCode().length() > 40 + || command.fulfillmentType() == null + || (!"STANDARD".equals(command.fulfillmentType()) + && !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType())) || command.totalPackageCount() != null && command.totalPackageCount() < 1) { throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整"); } @@ -142,7 +145,10 @@ public class InternalInboundOrderController { || command.customerName() == null || command.customerName().isBlank() || command.warehouseId() == null || command.warehouseId() <= 0 || command.warehouseName() == null || command.warehouseName().isBlank() - || command.businessType() == null || command.businessType().isBlank()) { + || command.businessType() == null || command.businessType().isBlank() + || command.fulfillmentType() == null + || (!"STANDARD".equals(command.fulfillmentType()) + && !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType()))) { throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java new file mode 100644 index 000000000..54f1a67be --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java @@ -0,0 +1,40 @@ +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.event.EventEnvelope; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.service.OverseasContainerEventConsumer; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequiredArgsConstructor +public class InternalOverseasContainerEventController { + private final OverseasContainerEventConsumer consumer; + @Value("${baiyue.security.internal-service-token}") private String expectedToken; + + @PostMapping("/internal/firstleg/v1/container-events") + public ApiResponse receive( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope event) { + authenticate(token); + return ApiResponse.success(consumer.consume(event)); + } + + private void authenticate(String token) { + if (token == null || expectedToken == null || expectedToken.isBlank() + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index 0decb1063..cfbb44d48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -128,6 +128,21 @@ public class PlatformContainerController { return ApiResponse.success(loadingLists.containerClearance(id)); } + @GetMapping("/{id}/clearance/document/{fileId}/download") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。") + public ResponseEntity downloadClearance( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @PathVariable @Positive long fileId) { + FirstlegScopeGuard.platform(user); + var download = loadingLists.downloadContainerClearance(id, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment() + .filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(download.contentType()).contentLength(download.content().length) + .body(download.content()); + } + @PostMapping("/{id}/clearance/document") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @Operation(summary = "上传或替换货柜目的港清关资料") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 153ae3d48..803be7cda 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -25,6 +25,10 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import org.springframework.http.MediaType; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -40,6 +44,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; +import java.nio.charset.StandardCharsets; + /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ @Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") @Validated @@ -124,6 +130,21 @@ public class PlatformLoadingListController { return ApiResponse.success(service.loadingDeclaration(planId, warehouseId)); } + @Operation(summary = "下载装柜清单出口报关附件", description = "完成态仍可下载;服务端校验装柜计划、仓库、当前附件及 File 业务归属。") + @GetMapping("/loading-lists/{planId}/customs/document/{fileId}/download") + @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") + public ResponseEntity downloadDeclaration( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @PathVariable @Positive long fileId, @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + var download = service.downloadLoadingDeclaration(planId, warehouseId, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(download.contentType()).contentLength(download.content().length) + .body(download.content()); + } + @Operation(summary = "上传或替换装柜清单出口报关文档") @PostMapping("/loading-lists/{planId}/customs/document") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java similarity index 83% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java index ca99b7399..ae441abed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java @@ -1,9 +1,9 @@ -package com.baiyue.firstleg.customerstatistics.controller; +package com.baiyue.firstleg.customerstatistics.adapter.in.web; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary; import com.baiyue.firstleg.service.FirstlegScopeGuard; import jakarta.annotation.Resource; import jakarta.validation.constraints.Positive; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java similarity index 85% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java index 0c208dc1e..93f276654 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java @@ -1,14 +1,13 @@ -package com.baiyue.firstleg.customerstatistics.mapper; +package com.baiyue.firstleg.customerstatistics.adapter.out.persistence; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; -import java.math.BigDecimal; -import java.time.LocalDateTime; - @Mapper -public interface CustomerTransitStatisticsMapper { +public interface CustomerTransitStatisticsMapper extends CustomerTransitStatisticsReader { + @Override @Select(""" SELECT #{customerId} AS customerId, COUNT(*) AS containerCount, @@ -49,8 +48,5 @@ public interface CustomerTransitStatisticsMapper { WHERE x.customer_id = #{customerId} ) scoped """) - SummaryRow summarize(@Param("customerId") long customerId); - - record SummaryRow(Long customerId, Long containerCount, BigDecimal totalTeu, - Long unknownContainerCount, LocalDateTime asOf) {} + SummaryProjection summarize(@Param("customerId") long customerId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java similarity index 75% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java index 485a9572b..71477ce5b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java @@ -1,9 +1,9 @@ -package com.baiyue.firstleg.customerstatistics.service; +package com.baiyue.firstleg.customerstatistics.application; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; +import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; @@ -12,11 +12,11 @@ import java.math.BigDecimal; @Service public class CustomerTransitStatisticsService { private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。"; - @Resource private CustomerTransitStatisticsMapper mapper; + @Resource private CustomerTransitStatisticsReader reader; public TransitSummary summarize(long customerId) { if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数"); - var row = mapper.summarize(customerId); + var row = reader.summarize(customerId); return new TransitSummary(customerId, value(row == null ? null : row.containerCount()), row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java similarity index 84% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java index e123871da..7afb319b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java @@ -1,4 +1,4 @@ -package com.baiyue.firstleg.customerstatistics.dto; +package com.baiyue.firstleg.customerstatistics.application.model; import java.math.BigDecimal; import java.time.LocalDateTime; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java new file mode 100644 index 000000000..58ece2751 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java @@ -0,0 +1,11 @@ +package com.baiyue.firstleg.customerstatistics.application.port.out; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public interface CustomerTransitStatisticsReader { + SummaryProjection summarize(long customerId); + + record SummaryProjection(Long customerId, Long containerCount, BigDecimal totalTeu, + Long unknownContainerCount, LocalDateTime asOf) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java index 8f12886f8..68a968aa4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java @@ -2,4 +2,10 @@ package com.baiyue.firstleg.customs.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsCaseMapper extends BaseMapper {} +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +@Mapper public interface CustomsCaseMapper extends BaseMapper { + @Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{containerBookingId} " + + "AND document_type='CUSTOMS_DECLARATION' AND completed_at IS NOT NULL") + int completedLoadingDeclaration(@Param("containerBookingId") long containerBookingId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java index f33063e95..3d8a35e42 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java @@ -81,6 +81,9 @@ public class CustomsRepository { .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); } + public boolean loadingDeclarationCompleted(long containerBookingId) { + return cases.completedLoadingDeclaration(containerBookingId) == 1; + } public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) { LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper() .eq(LoadingPlanEntity::getContainerBookingId, containerId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 38897b09a..61a048f5a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -1,7 +1,6 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.entity.ContainerDocumentEntity; -import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; import jakarta.annotation.Resource; @@ -9,7 +8,7 @@ import org.springframework.stereotype.Component; @Component public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor { - @Resource private LoadingListRepository repository; + @Resource private CustomsRepository repository; @Override public String code() { return "CUSTOMS_CLEARANCE"; } @@ -18,9 +17,8 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - ContainerDocumentEntity value = repository.document(context.containerBookingId(), "CUSTOMS_DECLARATION"); - if (value == null) return gate(false, "MISSING"); - return gate(value.getCompletedAt() != null, value.getCompletedAt() == null ? "UPLOADED" : "COMPLETED"); + boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId()); + return gate(completed, completed ? "COMPLETED" : "MISSING"); } private static GateItem gate(boolean passed, String actual) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java index 8abce64c9..07375297d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java @@ -14,6 +14,14 @@ public final class ContainerOverviewDtos { List timeline) {} public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo, String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName, + String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName, + String departurePortCode, String departurePortName, String destinationPortCode, + String destinationPortName, LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt, + LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt, + LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt, + LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt, + Long emptyReportSourceVersion, Integer version) {} public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus, String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java index f5a98f09a..e9071df30 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java @@ -17,7 +17,7 @@ public record InboundOrderSummary( Long customerId, String customerName, Long companyId, String companyName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, - String businessType, String channelCode, String channelName, + String businessType, String fulfillmentType, String channelCode, String channelName, String taxMode, String pricingTaxMode, String containerMode, Boolean customsInspection, String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName, Boolean holdFlag, String currentHoldReason, @@ -56,6 +56,7 @@ public record InboundOrderSummary( order.getCustomerId(), order.getCustomerName(), order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(), order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(), + order.getFulfillmentType(), order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getPricingTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java index 92665d4bd..ece4cea5e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java @@ -33,7 +33,8 @@ public record OrderApprovedInboundCommand( String expressTrackingNo, Integer totalPackageCount, String pickupMethod, - LocalDateTime requestedPickupAt + LocalDateTime requestedPickupAt, + String fulfillmentType ) { public OrderApprovedInboundCommand( long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, @@ -48,7 +49,7 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, totalPackageCount, null, null); + expressTrackingNo, totalPackageCount, null, null, "STANDARD"); } /** 兼容尚未携带订单总箱数的历史事件。 */ public OrderApprovedInboundCommand( @@ -63,7 +64,7 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, null, null, null); + expressTrackingNo, null, null, null, "STANDARD"); } /** 兼容未携带报价包税维度的历史调用方。 */ @@ -79,6 +80,6 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, null, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, null, null, null); + expressTrackingNo, null, null, null, "STANDARD"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index 68961190b..56523d82d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -7,20 +7,8 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm) {} public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, - Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount, - BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) { - public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, - Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount, - BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) { - this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm); - } - public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, - Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, - String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) { - this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,destinationWarehouseId, - destinationWarehouseCode,destinationWarehouseName,customerName,goodsName,availablePackageCount,unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm); - } - } + Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount, + BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) {} public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {} public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId, @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {} 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 0d1ec3a42..59922f76e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -54,6 +54,11 @@ public class ContainerBookingEntity { private LocalDateTime actualPortArrivalAt; private LocalDateTime estimatedWarehouseArrivalAt; private LocalDateTime actualWarehouseArrivalAt; + private LocalDateTime unloadingCompletedAt; + private LocalDateTime unloadingPhotoUploadedAt; + private LocalDateTime estimatedEmptyReportedAt; + private LocalDateTime actualEmptyReportedAt; + private Long emptyReportSourceVersion; private Long createdBy; private String createdByName; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerEventInboxEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerEventInboxEntity.java new file mode 100644 index 000000000..3fc1cec69 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerEventInboxEntity.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_container_event_inbox") +public class ContainerEventInboxEntity { + @TableId private String eventId; + private String eventType; + private Integer eventVersion; + private String aggregateId; + private Long aggregateVersion; + private String payloadHash; + private String payloadJson; + private String status; + private LocalDateTime receivedAt; + private LocalDateTime processedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java index 43290b179..75f165e1b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java @@ -38,6 +38,7 @@ public class InboundOrderEntity extends BaseEntity { private Long deliveryWarehouseId; private String deliveryWarehouseName; private String businessType; + private String fulfillmentType; private String channelCode; private String channelName; private Long carrierId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index e2703301b..52467a049 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -97,13 +97,19 @@ public class LoadingDeliveryService { @Transactional public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId, String operatorName, String key, CompleteLoadingRequest request) { + return completeLoading(tenantId, inboundId, inboundId, operatorId, operatorName, key, request); + } + + private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId, + long operatorId, String operatorName, String key, + CompleteLoadingRequest request) { String hash = hash(request); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash); if (replay != null) return replay; Snapshot inbound = requireInbound(tenantId, inboundId); CompletionTarget target = repository.completionTarget(tenantId, inboundId); if (target != null) { - return completeFulfillmentSlice(tenantId, inboundId, operatorId, operatorName, key, request, target); + return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName, key, request, target); } if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { loadingModelBridge.requireCompleted(tenantId, inboundId); @@ -114,7 +120,7 @@ public class LoadingDeliveryService { loadingModelBridge.requireCompleted(tenantId, inboundId); completionGate.requireReleasedBooking(tenantId, inboundId); requireCompletedPicking(tenantId, inboundId); - requireCompletionEvidence(tenantId, inboundId); + requireCompletionEvidence(tenantId, evidenceInboundId); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName); return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, @@ -129,13 +135,19 @@ public class LoadingDeliveryService { public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, long operatorId, String operatorName, String key, String remark) { + return completeAfterPdaSettlement(tenantId, inboundId, inboundId, operatorId, operatorName, key, remark); + } + + public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, + long evidenceInboundId, long operatorId, + String operatorName, String key, String remark) { CompletionTarget target = repository.completionTarget(tenantId, inboundId); long completionInboundId = target == null ? inboundId : target.completionInboundId(); Snapshot current = repository.findInboundForUpdate(tenantId, completionInboundId); if (current == null || current.version() == null) { throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试"); } - return completeLoading(tenantId, inboundId, operatorId, operatorName, key, + return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, key, new CompleteLoadingRequest(current.version(), remark)); } @@ -144,13 +156,17 @@ public class LoadingDeliveryService { * every external/business hard gate must pass before the PDA task is mutated. */ public void requirePdaCompletionReady(long tenantId, long inboundId) { + requirePdaCompletionReady(tenantId, inboundId, inboundId); + } + + public void requirePdaCompletionReady(long tenantId, long inboundId, long evidenceInboundId) { Snapshot inbound = requireInbound(tenantId, inboundId); if (!"LOADING".equals(inbound.processStatus())) { throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING"); } loadingModelBridge.requireStarted(tenantId, inboundId); completionGate.requireReleasedBooking(tenantId, inboundId); - requireCompletionEvidence(tenantId, inboundId); + requireCompletionEvidence(tenantId, evidenceInboundId); } private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId, @@ -167,7 +183,7 @@ public class LoadingDeliveryService { loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId()); completionGate.requireReleasedBooking(tenantId, target.sourceInboundId()); requireCompletedPicking(tenantId, target.sourceInboundId()); - requireCompletionEvidence(tenantId, target.sourceInboundId()); + requireCompletionEvidence(tenantId, evidenceInboundId); if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, request.expectedVersion()) != 1) throw concurrent(); repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 526bb2b11..62599bc9f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -107,6 +107,24 @@ public interface ContainerBookingMapper extends BaseMapper#{customerId}") int hasMismatchedCustomerItems(@Param("id") long id, @Param("customerId") long customerId); - @Update(""" UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerEventInboxMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerEventInboxMapper.java new file mode 100644 index 000000000..b09713b81 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerEventInboxMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.ContainerEventInboxEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContainerEventInboxMapper extends BaseMapper { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java index 2614509e7..f19381c0c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java @@ -4,7 +4,7 @@ import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity; import org.apache.ibatis.annotations.*; import java.util.List; @Mapper public interface ContainerOverviewMapper { - @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List documents(@Param("id") long id); + @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List documents(@Param("id") long id); @Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List transport(@Param("id") long id); @Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List exceptions(@Param("id") long id); @Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash); 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 4a1135079..bb29c8c8c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -38,7 +38,7 @@ public interface InboundSplitSettlementMapper { INSERT INTO fl_inbound_order (id,tenant_id,task_id,order_id,order_no,inbound_code,process_status, order_status_snapshot,measure_review_status,fee_status,fee_review_status,notification_status, expected_arrival_at,customer_id,customer_name,company_id,company_name,warehouse_id,warehouse_name, - delivery_warehouse_id,delivery_warehouse_name,business_type,channel_code,channel_name,carrier_id,carrier_name, + delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type,channel_code,channel_name,carrier_id,carrier_name, tax_mode,pricing_tax_mode,container_mode,customs_inspection,destination_type,destination_warehouse_id, destination_code,destination_name,hold_flag,current_hold_reason,total_package_count,package_count, actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,pallet_count,volume_weight_kg, @@ -48,7 +48,7 @@ public interface InboundSplitSettlementMapper { SELECT #{childId},tenant_id,task_id,#{childOrderId},#{childOrderNo},#{childInboundCode}, CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END,order_status_snapshot, 'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name, - company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type, + company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type, channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection, destination_type,destination_warehouse_id,destination_code,destination_name,hold_flag,current_hold_reason, #{packageCount},#{packageCount}, @@ -149,6 +149,34 @@ public interface InboundSplitSettlementMapper { @Param("childMeasurementItemId") long childMeasurementItemId, @Param("lineNo") int lineNo, @Param("packageCount") int packageCount); + @Update(""" + UPDATE fl_inbound_carton_label + SET inbound_id=#{childInboundId}, order_id=#{childOrderId}, inbound_code=#{childInboundCode}, + carton_index=carton_index-#{loadedPackageCount}, + total_carton_count=#{remainingPackageCount}, + detail_package_count=LEAST(detail_package_count,#{remainingPackageCount}) + WHERE tenant_id=#{accountId} AND inbound_id=#{sourceInboundId} AND batch_status='ACTIVE' + ORDER BY carton_index DESC + LIMIT #{remainingPackageCount} + """) + int transferRemainingCartonLabels(@Param("accountId") long accountId, + @Param("sourceInboundId") long sourceInboundId, + @Param("childInboundId") long childInboundId, + @Param("childOrderId") long childOrderId, + @Param("childInboundCode") String childInboundCode, + @Param("loadedPackageCount") int loadedPackageCount, + @Param("remainingPackageCount") int remainingPackageCount); + + @Update(""" + UPDATE fl_inbound_carton_label + SET total_carton_count=#{loadedPackageCount}, + detail_package_count=LEAST(detail_package_count,#{loadedPackageCount}) + WHERE tenant_id=#{accountId} AND inbound_id=#{sourceInboundId} AND batch_status='ACTIVE' + """) + int normalizeLoadedCartonLabels(@Param("accountId") long accountId, + @Param("sourceInboundId") long sourceInboundId, + @Param("loadedPackageCount") int loadedPackageCount); + @Update(""" UPDATE fl_inbound_order SET package_count = GREATEST(COALESCE(package_count, total_package_count) - #{packageCount}, 0), @@ -177,9 +205,10 @@ public interface InboundSplitSettlementMapper { @Update(""" UPDATE fl_inbound_measurement_item - SET package_count=#{packageCount}, - total_volume_cbm=unit_volume_cbm*#{packageCount}, - volume_weight_kg=volume_weight_kg/NULLIF(package_count,0)*#{packageCount}, + SET total_volume_cbm=COALESCE(unit_volume_cbm,0)*#{packageCount}, + volume_weight_kg=CASE WHEN #{packageCount}=0 THEN 0 + ELSE COALESCE(volume_weight_kg/NULLIF(package_count,0)*#{packageCount},0) END, + package_count=#{packageCount}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{measurementItemId} AND tenant_id=#{accountId} """) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java new file mode 100644 index 000000000..3c30e0806 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +/** 读取国内已打印的稳定箱唛池,供装柜完成事件按实装箱数抽取冻结。 */ +@Mapper +public interface LoadingCartonManifestMapper { + @Select(""" + SELECT inbound_detail_id, box_code FROM fl_inbound_carton_label + WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND batch_status='ACTIVE' + ORDER BY carton_index,id + """) + List labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Getter @Setter + class LabelRow { + private Long inboundDetailId; + private String boxCode; + } +} 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 06cd7e422..84786775d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -19,9 +19,9 @@ public interface LoadingCompletedEventMapper { COALESCE(o.root_inbound_id, o.id) AS root_inbound_id, o.order_id AS source_order_id, o.order_no AS source_order_no, COALESCE(root_order.order_id, o.order_id) AS root_order_id, - o.inbound_code, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, + o.inbound_code, o.business_type, o.fulfillment_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, o.destination_name AS destination_warehouse_name, - i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, + i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, 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 @@ -29,6 +29,14 @@ public interface LoadingCompletedEventMapper { 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 + JOIN fl_loading_pick_item actual_pick + 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 p.plan_status = 'COMPLETED' @@ -38,12 +46,42 @@ public interface LoadingCompletedEventMapper { """) List rows(@Param("planId") long planId); + /** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */ + @Select(""" + SELECT i.account_id, i.inbound_id AS source_inbound_id, + a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count, + i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, + i.product_name_zh, i.package_count, i.product_quantity, + label.box_code + FROM fl_loading_plan_item i + JOIN fl_loading_pick_item actual_pick + 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 = #{planId} + JOIN fl_inbound_pallet_allocation a + ON a.tenant_id = i.account_id AND a.inbound_id = i.inbound_id + AND a.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED') + AND a.picked_package_count > 0 + LEFT JOIN fl_inbound_carton_label label + ON label.tenant_id = i.account_id + AND label.inbound_detail_id = i.inbound_detail_id AND label.batch_status = 'ACTIVE' + WHERE i.loading_plan_id = #{planId} AND i.deleted = 0 + ORDER BY i.account_id, i.inbound_id, a.allocation_no, + i.inbound_detail_id, label.carton_index, label.id + """) + List palletRows(@Param("planId") long planId); + @Getter @Setter class Row { private Long loadingPlanId; private String planNo; private Long containerBookingId; private Integer aggregateVersion; private String containerNo; private String cabinetNo; private Long warehouseId; private String warehouseName; private Long accountId; private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId; + private Long loadingPlanItemId; private String sourceOrderNo; private Long rootOrderId; private String inboundCode; private Long destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; private Long sourceInboundDetailId; private String skuCode; @@ -51,6 +89,15 @@ public interface LoadingCompletedEventMapper { private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; + private String businessType; private String fulfillmentType; private String destinationType; private String customerRemark; private String internalRemark; private String warehouseRemark; } + + @Getter @Setter + class PalletRow { + private Long accountId; private Long sourceInboundId; private String palletCode; + private Integer allocationNo; private Integer allocatedPackageCount; + private Long sourceInboundDetailId; private String skuCode; private String productNameZh; + private Integer packageCount; private Integer productQuantity; private String boxCode; + } } 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 ea943e0b9..a39cba50e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -72,11 +72,25 @@ public interface LoadingPickItemMapper extends BaseMapper @Param("operatorName") String operatorName); @Select(""" - SELECT COALESCE(SUM(plan_item.package_count),0) expectedCount, + SELECT COALESCE(SUM(CASE + WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0 + WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0 + AND pick_item.picked_package_count<=plan_item.package_count + THEN pick_item.picked_package_count + ELSE plan_item.package_count + END),0) expectedCount, COALESCE(SUM(pick_item.picked_package_count),0) pickedCount, COUNT(DISTINCT pick_task.id) taskCount, COUNT(DISTINCT CASE WHEN pick_task.task_status='COMPLETED' THEN pick_task.id END) completedTaskCount, - SUM(CASE WHEN pick_item.id IS NULL OR pick_item.picked_package_count<>plan_item.package_count THEN 1 ELSE 0 END) unresolvedCount + SUM(CASE + WHEN pick_item.id IS NULL THEN 1 + WHEN pick_item.item_status='PICKED' + AND pick_item.picked_package_count=plan_item.package_count THEN 0 + WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0 + AND pick_item.picked_package_count<=plan_item.package_count THEN 0 + WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0 + ELSE 1 + END) unresolvedCount FROM fl_loading_plan_item plan_item JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id AND inbound_order.tenant_id=#{tenantId} AND inbound_order.deleted=0 @@ -107,10 +121,12 @@ public interface LoadingPickItemMapper extends BaseMapper } @Select(""" - SELECT DISTINCT plan_item.account_id tenantId,plan_item.inbound_id inboundId + SELECT plan_item.account_id tenantId,plan_item.inbound_id inboundId FROM fl_loading_pick_item pick_item JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id WHERE pick_item.pick_task_id=#{taskId} + GROUP BY plan_item.account_id,plan_item.inbound_id + ORDER BY MIN(pick_item.id) """) java.util.List selectInboundOwners(@Param("taskId") long taskId); @@ -214,7 +230,8 @@ public interface LoadingPickItemMapper extends BaseMapper long unresolvedCount(@Param("taskId") long taskId); /** - * 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。 + * 以结算后的装柜计划事实为完成口径,不能只检查当前 PDA 任务中的明细。 + * PARTIAL 在本查询前已把计划箱数收敛为实际拣货箱数;收敛前的严格小于关系由结算计划校验。 */ @Select(""" SELECT COUNT(*) @@ -228,7 +245,15 @@ public interface LoadingPickItemMapper extends BaseMapper WHERE pick_item.loading_plan_item_id = plan_item.id AND pick_task.loading_plan_id = #{planId} AND pick_task.task_status = 'LOADING' - AND pick_item.picked_package_count = plan_item.package_count + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = plan_item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count = plan_item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) ) """) long unfinishedCount(@Param("planId") long planId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 955715c40..9d1a34e60 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -79,12 +79,19 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba WHERE inbound_detail_id IS NOT NULL AND package_count>0 GROUP BY tenant_id,measurement_id,inbound_detail_id ) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id - WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='WAIT_SPLIT' + WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} + AND (o.process_status='WAIT_SPLIT' OR (o.process_status='LOADING' AND EXISTS ( + SELECT 1 FROM fl_loading_plan_item same_plan_item + WHERE same_plan_item.loading_plan_id=#{planId} + AND same_plan_item.account_id=o.tenant_id + AND same_plan_item.inbound_id=o.id + AND same_plan_item.deleted=0 + ))) AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND i.inbound_detail_id=#{detailId} ORDER BY m.id DESC LIMIT 1 FOR UPDATE """) - Candidate candidate(@Param("warehouseId") long warehouseId,@Param("inboundId") long inboundId, - @Param("detailId") long detailId); + Candidate candidate(@Param("planId") long planId,@Param("warehouseId") long warehouseId, + @Param("inboundId") long inboundId,@Param("detailId") long detailId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index a6ed9d70d..486fb960e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -27,6 +27,11 @@ public class PlatformOceanBookingController { @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); } + @GetMapping("/link-candidates") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") + public ApiResponse> linkCandidates( + @AuthenticationPrincipal AuthenticatedUser user, @Valid LinkCandidateQuery query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkCandidates(query)); + } @GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> channels(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long originWarehouseId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 044d6b76f..6d049a277 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -26,6 +26,22 @@ public final class OceanBookingDtos { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } + public record LinkCandidateQuery(@NotNull @Positive Long warehouseId, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 32) String containerType, + @Min(1) Long page, + @Min(1) @Max(200) Long size) { + public LinkCandidateQuery { + if (page == null) page = 1L; + if (size == null) size = 20L; + } + } + + public record LinkCandidate(Long oceanBookingId, String bookingNo, Long requirementId, + String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg, + int requiredQuantity, long allocatedQuantity, long remainingQuantity, + String customerName, String warehouseName, String channelName) {} + public record PartyRequest(@NotBlank @Size(max = 24) String partyType, @NotBlank @Size(max = 255) String partyName, @Size(max = 1000) String address, @@ -44,6 +60,7 @@ public final class OceanBookingDtos { @Size(max = 128) String billOfLadingNo, @Positive Long shippingCompanyId, @Size(max = 128) String shippingCompanyName, + @Size(max = 16) String carrierCode, @Positive Long bookingAgentId, @Size(max = 128) String bookingAgentName, @Size(max = 128) String vesselName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java index f7cc765f5..4e79948fd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java @@ -27,6 +27,7 @@ public class OceanBookingEntity { private String billOfLadingNo; private Long shippingCompanyId; private String shippingCompanyName; + private String carrierCode; private Long bookingAgentId; private String bookingAgentName; private String vesselName; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java index cca20bf30..77b31e191 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java @@ -3,8 +3,21 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper { @Update("UPDATE fl_ocean_booking_container_allocation SET container_booking_id=#{targetId} WHERE container_booking_id=#{sourceId}") int reassign(@Param("sourceId") long sourceId, @Param("targetId") long targetId); + + @Select(""" + SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation + JOIN fl_container_booking container ON container.id=allocation.container_booking_id + AND container.record_status='ACTIVE' + AND container.ocean_booking_id=allocation.ocean_booking_id + AND container.ocean_booking_requirement_id=allocation.requirement_id + WHERE allocation.ocean_booking_id=#{oceanBookingId} + AND allocation.requirement_id=#{requirementId} + """) + long countActiveLinked(@Param("oceanBookingId") long oceanBookingId, + @Param("requirementId") long requirementId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index d806df7a9..a8aa3e557 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.oceanbooking.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Delete; @@ -13,7 +15,7 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface OceanBookingMapper extends BaseMapper { - @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") + @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int deleteDraft(@Param("id") long id, @Param("version") int version); @@ -24,9 +26,9 @@ public interface OceanBookingMapper extends BaseMapper { WHERE party.ocean_booking_id=booking.id AND party.party_type='SHIPPER' LIMIT 1) shipper_name, (SELECT party.party_name FROM fl_ocean_booking_party party WHERE party.ocean_booking_id=booking.id AND party.party_type='CONSIGNEE' LIMIT 1) consignee_name, - (SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation - JOIN fl_container_booking container ON container.id=allocation.container_booking_id - WHERE allocation.ocean_booking_id=booking.id AND container.record_status='ACTIVE') container_count + COALESCE((SELECT SUM(requirement.quantity) + FROM fl_ocean_booking_container_requirement requirement + WHERE requirement.ocean_booking_id=booking.id),0) container_count FROM fl_ocean_booking booking WHERE (#{query.customerId} IS NULL OR booking.customer_id=#{query.customerId}) AND booking.status IN ('DRAFT','RELEASED') @@ -41,6 +43,36 @@ public interface OceanBookingMapper extends BaseMapper { """) Page selectPage(Page page, @Param("query") Query query); + @Select(""" + SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id, + requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg, + requirement.quantity required_quantity, + COALESCE(allocated.allocated_quantity,0) allocated_quantity, + requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity, + booking.customer_name,booking.warehouse_name,booking.channel_name + FROM fl_ocean_booking booking + JOIN fl_ocean_booking_container_requirement requirement + ON requirement.ocean_booking_id=booking.id + LEFT JOIN ( + SELECT allocation.ocean_booking_id,allocation.requirement_id,COUNT(*) allocated_quantity + FROM fl_ocean_booking_container_allocation allocation + JOIN fl_container_booking container ON container.id=allocation.container_booking_id + AND container.record_status='ACTIVE' + AND container.ocean_booking_id=allocation.ocean_booking_id + AND container.ocean_booking_requirement_id=allocation.requirement_id + GROUP BY allocation.ocean_booking_id,allocation.requirement_id + ) allocated ON allocated.ocean_booking_id=booking.id + AND allocated.requirement_id=requirement.id + WHERE booking.status='RELEASED' + AND booking.warehouse_id=#{query.warehouseId} + AND booking.channel_code=#{query.channelCode} + AND requirement.container_type=#{query.containerType} + AND requirement.quantity>COALESCE(allocated.allocated_quantity,0) + ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC + """) + Page selectLinkCandidates(Page page, + @Param("query") LinkCandidateQuery query); + @Update(""" UPDATE fl_ocean_booking SET status=#{toStatus}, updated_by=#{operatorId},updated_by_name=#{operatorName}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index 1f5f9b27c..ca7f77216 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.RealContainer; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.mapper.*; import com.baiyue.firstleg.entity.ContainerBookingEntity; @@ -30,6 +32,9 @@ public class OceanBookingRepository { public PageResult page(Query query) { return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query)); } + public PageResult linkCandidates(LinkCandidateQuery query) { + return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query)); + } public OceanBookingEntity find(long id) { return bookings.selectById(id); } public void insert(OceanBookingEntity value) { bookings.insert(value); } public int transition(long id, String from, String to, int version, long operatorId, String operatorName) { @@ -44,6 +49,9 @@ public class OceanBookingRepository { public List parties(long id) { return parties.selectList(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); } public List requirements(long id) { return requirements.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); } public List allocations(long id) { return allocations.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); } + public long activeLinkedCount(long oceanBookingId, long requirementId) { + return allocations.countActiveLinked(oceanBookingId, requirementId); + } public List containers(long id) { Map requirementById = requirements(id).stream() .collect(Collectors.toMap(OceanBookingContainerRequirementEntity::getId, Function.identity())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 5db6c6f52..624a2f2b8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -11,9 +11,9 @@ import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator; import com.fasterxml.jackson.core.JsonProcessingException; @@ -36,10 +36,11 @@ import java.util.Set; @Service public class OceanBookingService { private static final Set PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY"); + private static final Set CARRIER_CODES = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"); private static final Set OPERATIONAL_LIST_STATUSES = Set.of( OceanBookingStatus.DRAFT, OceanBookingStatus.RELEASED); @Resource private OceanBookingRepository repository; - @Resource private PriceBookQueryRepository priceBooks; + @Resource private PriceBookQueryUseCase priceBooks; @Resource private ObjectMapper objectMapper; @Resource private WarehouseSupplierSnapshotService suppliers; @Resource private WarehouseMasterDataClient warehouses; @@ -55,6 +56,11 @@ public class OceanBookingService { return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size())); } + public PageResult linkCandidates(LinkCandidateQuery query) { + return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), + query.channelCode().trim(), query.containerType().trim(), query.page(), query.size())); + } + public List channels(long originWarehouseId) { if (originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); Long versionId = priceBooks.activeVersionId(); @@ -191,6 +197,10 @@ public class OceanBookingService { ContainerSpecificationCatalog.requireMatch(container.containerType(), container.capacityCbm(), container.maxWeightKg()); } } + if (StringUtils.hasText(data.carrierCode()) + && !CARRIER_CODES.contains(data.carrierCode().trim().toUpperCase())) { + throw badRequest("carrierCode仅支持MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER"); + } } private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } private static void validateRelease(OceanBookingEntity booking, List parties, @@ -201,6 +211,7 @@ public class OceanBookingService { || !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm()) || !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null || booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null + || !StringUtils.hasText(booking.getCarrierCode()) || booking.getBookingAgentId() == null || parties == null || parties.size() != 3 || requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布"); validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt()); @@ -247,6 +258,7 @@ public class OceanBookingService { v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName()); v.setBillOfLadingNo(trim(d.billOfLadingNo())); v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName())); + v.setCarrierCode(d.carrierCode() == null ? null : d.carrierCode().trim().toUpperCase()); v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(resolved.bookingAgentName()); v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo())); v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java index 36f8e7bd7..73e73b70c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java @@ -28,33 +28,39 @@ public class PlatformOutboundDispatchController { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(query)); } - @GetMapping("/detail") + @GetMapping("/{containerBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:view')") - public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(query)); + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(containerBookingId)); } - @GetMapping("/check-gate") + @GetMapping("/{containerBookingId}/gate") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:check')") - public ApiResponse checkGate(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(query)); + public ApiResponse checkGate(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(containerBookingId)); } - @PostMapping("/confirm-dispatch") + @PostMapping("/{containerBookingId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:confirm')") - @BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="确认国内出仓") + @BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级确认国内出仓") public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(user.userId(), user.username(), key, request)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm( + containerBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/reverse-dispatch") + @PostMapping("/{containerBookingId}/reverse") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:reverse')") - @BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="撤销国内出仓") + @BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级撤销国内出仓") public ApiResponse reverse(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse(user.userId(), user.username(), key, request)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse( + containerBookingId, user.userId(), user.username(), key, request)); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java index 38c848ca6..b7c8dc1b6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.outbound.dispatch.dto; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; @@ -10,21 +11,27 @@ import java.util.List; public final class OutboundDispatchDtos { private OutboundDispatchDtos() {} - public record Query(@Positive Long orderId, @Positive Long inboundId, @Positive Long containerBookingId) {} + public record Query(@NotNull @Positive Long containerBookingId) {} - public record ActionRequest(@Positive Long inboundId, @Positive Long containerBookingId, - @PositiveOrZero int expectedVersion, @Size(max = 500) String remark) {} + public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion, + @Size(max = 500) String remark) {} public record GateItem(String code, String name, boolean passed, String actual, String requirement) {} public record GateResult(boolean passed, List items, LocalDateTime checkedAt) {} - public record Aggregate(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId, - Long customerId, String customerName, String inboundStatus, - Long containerBookingId, String cabinetNo, String containerNo, + public record InboundBinding(Long orderId, String orderNo, Long inboundId, String inboundCode, + Long tenantId, Long customerId, String customerName, + String inboundStatus, Long pickTaskId, String pickTaskStatus) {} + + public record InboundGate(Long inboundId, String inboundCode, GateResult gate) {} + public record ContainerGateResult(boolean passed, List inbounds, LocalDateTime checkedAt) {} + + public record Aggregate(Long containerBookingId, String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus, - Long pickTaskId, String pickTaskStatus, Long oceanBookingId, String oceanBookingStatus, + Long oceanBookingId, String oceanBookingStatus, Long truckAppointmentId, String truckAppointmentStatus, - String dispatchStatus, Integer version, GateResult gate) {} + String dispatchStatus, Integer version, List inbounds, + ContainerGateResult gate) {} public record TimelineItem(String module, String action, String fromStatus, String toStatus, Long operatorId, String operatorName, String remark, LocalDateTime occurredAt) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java index 09fb7a8a7..9bbcf97e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java @@ -14,6 +14,11 @@ public interface OutboundDispatchMapper extends BaseMapper SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, inbound.customer_name customerName,inbound.process_status inboundStatus, @@ -22,45 +21,62 @@ public interface OutboundDispatchReadMapper { JOIN fl_container_booking booking ON booking.id=plan.container_booking_id JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0 WHERE item.deleted=0 - AND inbound.order_id=#{orderId} - AND inbound.id=#{inboundId} - AND booking.id=#{containerBookingId} + AND booking.id=#{containerBookingId} ORDER BY inbound.id,booking.id - """) - List bindings(@Param("orderId") Long orderId, @Param("inboundId") Long inboundId, - @Param("containerBookingId") Long containerBookingId); + List bindings(@Param("containerBookingId") long containerBookingId); + + @Select(""" + SELECT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, + inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, + inbound.customer_name customerName,inbound.process_status inboundStatus, + booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo, + plan.id loadingPlanId,plan.plan_status loadingPlanStatus + FROM fl_loading_plan_item item + JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0 + WHERE item.deleted=0 AND booking.id=#{containerBookingId} + ORDER BY inbound.id,booking.id + FOR UPDATE + """) + List bindingsForUpdate(@Param("containerBookingId") long containerBookingId); @Select("SELECT id,task_status status FROM fl_loading_pick_task WHERE loading_plan_id=#{planId} " + "ORDER BY started_at DESC,id DESC LIMIT 1") IdStatus pick(@Param("planId") long planId); @Select(""" - SELECT booking.id,booking.status + SELECT booking.id,booking.status,booking.customer_id customerId FROM fl_ocean_booking_container_allocation allocation JOIN fl_ocean_booking booking ON booking.id=allocation.ocean_booking_id - WHERE allocation.container_booking_id=#{containerBookingId} AND booking.customer_id=#{tenantId} + WHERE allocation.container_booking_id=#{containerBookingId} ORDER BY booking.updated_at DESC,booking.id DESC LIMIT 1 """) - IdStatus ocean(@Param("containerBookingId") long containerBookingId, @Param("tenantId") long tenantId); + OceanBinding ocean(@Param("containerBookingId") long containerBookingId); @Select(""" SELECT id,status FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId} - AND customer_id=#{tenantId} ORDER BY updated_at DESC,id DESC LIMIT 1 """) - IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId, - @Param("tenantId") long tenantId); + IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId); @Select(""" - SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN - ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount, - MAX(CASE WHEN status='ACTIVE' AND evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL - AND seal_no<>'' THEN 1 ELSE 0 END) validSeal - FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} + SELECT COUNT(DISTINCT CASE WHEN evidence.status='ACTIVE' AND evidence.file_id IS NOT NULL + AND evidence.evidence_type IN ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') + THEN evidence.evidence_type END) evidenceCount, + MAX(CASE WHEN evidence.status='ACTIVE' AND evidence.evidence_type='SEAL' + AND evidence.file_id IS NOT NULL AND evidence.seal_no IS NOT NULL + AND evidence.seal_no<>'' THEN 1 ELSE 0 END) validSeal + FROM fl_loading_plan plan + JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0 + JOIN fl_loading_evidence evidence ON evidence.inbound_id=item.inbound_id + AND evidence.tenant_id=item.account_id + WHERE plan.id=#{loadingPlanId} AND plan.container_booking_id=#{containerBookingId} """) - Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + Evidence evidence(@Param("containerBookingId") long containerBookingId, + @Param("loadingPlanId") long loadingPlanId); @Select(""" SELECT module,action,fromStatus,toStatus,operatorId,operatorName,remark,occurredAt FROM ( @@ -87,6 +103,25 @@ public interface OutboundDispatchReadMapper { SELECT 'TRUCK_APPOINTMENT',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at FROM fl_truck_appointment_history h WHERE h.appointment_id=#{truckAppointmentId} UNION ALL + SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.note,h.created_at + FROM fl_customs_history h JOIN fl_customs_case customs_case ON customs_case.id=h.customs_case_id + WHERE customs_case.tenant_id=#{tenantId} AND customs_case.order_id=#{orderId} + AND customs_case.inbound_id=#{inboundId} AND customs_case.container_booking_id=#{containerBookingId} + UNION ALL + SELECT 'SAILING_SCHEDULE',h.action,NULL,NULL,h.operator_id,h.operator_name,h.reason,h.created_at + FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule ON port_schedule.id=h.port_schedule_id + WHERE port_schedule.customer_id=#{tenantId} AND port_schedule.ocean_booking_id=#{oceanBookingId} + AND port_schedule.container_booking_id=#{containerBookingId} + AND EXISTS ( + SELECT 1 FROM fl_loading_plan_item schedule_item + JOIN fl_loading_plan schedule_plan ON schedule_plan.id=schedule_item.loading_plan_id + JOIN fl_inbound_order schedule_inbound ON schedule_inbound.id=schedule_item.inbound_id + AND schedule_inbound.tenant_id=schedule_item.account_id AND schedule_inbound.deleted=0 + WHERE schedule_item.deleted=0 AND schedule_plan.container_booking_id=port_schedule.container_booking_id + AND schedule_inbound.tenant_id=#{tenantId} AND schedule_inbound.order_id=#{orderId} + AND schedule_inbound.id=#{inboundId} + ) + UNION ALL SELECT 'OUTBOUND_DISPATCH',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at FROM fl_outbound_dispatch_history h WHERE h.tenant_id=#{tenantId} AND h.inbound_id=#{inboundId} AND h.container_booking_id=#{containerBookingId} @@ -95,6 +130,7 @@ public interface OutboundDispatchReadMapper { List timeline(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId, + @Param("orderId") long orderId, @Param("oceanBookingId") Long oceanBookingId, @Param("truckAppointmentId") Long truckAppointmentId); @@ -102,5 +138,6 @@ public interface OutboundDispatchReadMapper { Long customerId, String customerName, String inboundStatus, Long containerBookingId, String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {} record IdStatus(Long id, String status) {} + record OceanBinding(Long id, String status, Long customerId) {} record Evidence(Integer evidenceCount, Integer validSeal) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index dc882f01c..cf510a1e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -1,7 +1,6 @@ package com.baiyue.firstleg.outbound.dispatch.repository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.Query; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineItem; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity; @@ -22,22 +21,24 @@ public class OutboundDispatchRepository { @Resource private OutboundDispatchOperationMapper operations; @Resource private OutboundDispatchReadMapper reads; - public List bindings(Query query) { - return reads.bindings(query.orderId(), query.inboundId(), query.containerBookingId()); + public List bindings(long containerId) { return reads.bindings(containerId); } + public List lockBindings(long containerId) { + return reads.bindingsForUpdate(containerId); } public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); } - public OutboundDispatchReadMapper.IdStatus ocean(long containerId, long tenantId) { - return reads.ocean(containerId, tenantId); + public OutboundDispatchReadMapper.OceanBinding ocean(long containerId) { return reads.ocean(containerId); } + public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) { + return reads.truck(containerId, planId); } - public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId, long tenantId) { - return reads.truck(containerId, planId, tenantId); - } - public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) { - return reads.evidence(tenantId, inboundId); + public OutboundDispatchReadMapper.Evidence evidence(long containerId, long loadingPlanId) { + return reads.evidence(containerId, loadingPlanId); } public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) { return dispatches.find(tenantId, inboundId, containerId); } + public OutboundDispatchEntity dispatchLocked(long tenantId, long inboundId, long containerId) { + return dispatches.findForUpdate(tenantId, inboundId, containerId); + } public void insert(OutboundDispatchEntity value) { dispatches.insert(value); } public int confirm(long id, int version, long operatorId, String operatorName, String remark) { return dispatches.confirm(id, version, operatorId, operatorName, remark); @@ -57,6 +58,6 @@ public class OutboundDispatchRepository { } public List timeline(OutboundDispatchReadMapper.Binding binding, Long oceanId, Long truckId) { return reads.timeline(binding.tenantId(), binding.inboundId(), binding.containerBookingId(), - binding.loadingPlanId(), oceanId, truckId); + binding.loadingPlanId(), binding.orderId(), oceanId, truckId); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index ed32bcb61..0568fef73 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -10,6 +10,8 @@ import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntit import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper; import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,15 +23,18 @@ import java.util.*; @Service public class OutboundDispatchService { - private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); - private static final Set TRUCK_READY = Set.of("DEPARTED", "COMPLETED"); + private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE"); private final OutboundDispatchRepository repository; private final Map contributors; + private final CarrierScheduleQueryService carrierSchedules; + @Autowired public OutboundDispatchService(OutboundDispatchRepository repository, - List contributors) { + List contributors, + CarrierScheduleQueryService carrierSchedules) { this.repository = repository; + this.carrierSchedules = carrierSchedules; Map indexed = new HashMap<>(); for (OutboundDispatchGateContributor contributor : contributors) { if (indexed.put(contributor.code(), contributor) != null) { @@ -39,86 +44,140 @@ public class OutboundDispatchService { this.contributors = Map.copyOf(indexed); } + public OutboundDispatchService(OutboundDispatchRepository repository, + List contributors) { + this(repository, contributors, null); + } + public List list(Query query) { - requireSelector(query); - return repository.bindings(query).stream().map(this::aggregate).toList(); + return List.of(aggregate(bindings(query.containerBookingId()))); } - public Detail detail(Query query) { - OutboundDispatchReadMapper.Binding binding = single(query); - Aggregate aggregate = aggregate(binding); - return new Detail(aggregate, repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId())); + public Detail detail(long containerBookingId) { + List bindings = bindings(containerBookingId); + Aggregate aggregate = aggregate(bindings); + List timeline = bindings.stream() + .flatMap(binding -> repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId()).stream()) + .distinct() + .sorted(Comparator.comparing(TimelineItem::occurredAt, + Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(TimelineItem::module)) + .toList(); + return new Detail(aggregate, timeline); } - public GateResult checkGate(Query query) { return aggregate(single(query)).gate(); } + public ContainerGateResult checkGate(long containerBookingId) { + return aggregate(bindings(containerBookingId)).gate(); + } @Transactional - public Detail confirm(long operatorId, String operatorName, String key, ActionRequest request) { + public Detail confirm(long containerBookingId, long operatorId, String operatorName, + String key, ActionRequest request) { validateKey(key); - Query query = new Query(null, request.inboundId(), request.containerBookingId()); - OutboundDispatchReadMapper.Binding binding = single(query); - String hash = hash("CONFIRM", request); - Detail replay = replay(operatorId, "CONFIRM_DISPATCH", key, hash, query); + String hash = hash("CONFIRM", containerBookingId, request); + Detail replay = replay(containerBookingId, operatorId, "CONFIRM_DISPATCH", key, hash); if (replay != null) return replay; - Aggregate before = aggregate(binding); - if (!before.gate().passed()) throw conflict("国内出仓门禁未通过"); - OutboundDispatchEntity state = state(binding); - if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); - if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { - throw conflict("当前出仓单已确认派车"); + List bindings = lockedBindings(containerBookingId); + Aggregate before = aggregate(bindings); + if (!before.gate().passed()) throw conflict("柜内全部入库绑定尚未通过国内出仓门禁"); + List states = bindings.stream().map(this::stateLocked).toList(); + StateView visible = visibleState(states); + if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); + if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) { + throw conflict("当前货柜已确认离仓"); } - if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { - throw concurrent(); + for (OutboundDispatchEntity state : states) { + if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + throw concurrent(); + } } - history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), + OutboundDispatchEntity representative = states.get(0); + history(representative, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName); - if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent(); - return detail(query); + + OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); + if (carrierSchedules != null) { + if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务"); + } + carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId, + operatorId, operatorName); + } + if (repository.completeOperation(representative.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) { + throw concurrent(); + } + return detail(containerBookingId); } @Transactional - public Detail reverse(long operatorId, String operatorName, String key, ActionRequest request) { + public Detail reverse(long containerBookingId, long operatorId, String operatorName, + String key, ActionRequest request) { validateKey(key); - Query query = new Query(null, request.inboundId(), request.containerBookingId()); - OutboundDispatchReadMapper.Binding binding = single(query); - String hash = hash("REVERSE", request); - Detail replay = replay(operatorId, "REVERSE_DISPATCH", key, hash, query); + String hash = hash("REVERSE", containerBookingId, request); + Detail replay = replay(containerBookingId, operatorId, "REVERSE_DISPATCH", key, hash); if (replay != null) return replay; - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); - if (state == null || !OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { - throw conflict("当前出仓单不在等待装船状态"); + List bindings = lockedBindings(containerBookingId); + List states = bindings.stream().map(this::existingStateLocked).toList(); + StateView visible = visibleState(states); + if (!OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) { + throw conflict("当前货柜不在已确认离仓状态"); } - if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); - if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); + OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); + if (carrierSchedules != null) { + if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓"); + } + if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) { + throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓"); + } + } + for (OutboundDispatchEntity state : states) { + if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + throw concurrent(); + } + } + OutboundDispatchEntity representative = states.get(0); + history(representative, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), + OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName); + if (repository.completeOperation(representative.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) { throw concurrent(); } - history(state, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), - OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName); - if (repository.completeOperation(state.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) throw concurrent(); - return detail(query); + return detail(containerBookingId); } - private Aggregate aggregate(OutboundDispatchReadMapper.Binding binding) { - var pick = repository.pick(binding.loadingPlanId()); - var ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); - var truck = repository.truck(binding.containerBookingId(), binding.loadingPlanId(), binding.tenantId()); - var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); - var dispatch = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); - GateResult gate = gate(binding, pick, ocean, truck, evidence); - return new Aggregate(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(), - binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(), - binding.containerBookingId(), binding.cabinetNo(), binding.containerNo(), - binding.loadingPlanId(), binding.loadingPlanStatus(), - id(pick), status(pick), id(ocean), status(ocean), id(truck), status(truck), - dispatch == null ? OutboundDispatchStatus.READY_FOR_DISPATCH.name() : dispatch.getStatus(), - dispatch == null ? 0 : dispatch.getVersion(), gate); + private Aggregate aggregate(List bindings) { + OutboundDispatchReadMapper.Binding first = validateContainer(bindings); + var pick = repository.pick(first.loadingPlanId()); + var ocean = repository.ocean(first.containerBookingId()); + var truck = repository.truck(first.containerBookingId(), first.loadingPlanId()); + var evidence = repository.evidence(first.containerBookingId(), first.loadingPlanId()); + List containerContributorGates = contributorGates(first, ocean); + List inboundGates = new ArrayList<>(); + List inboundDtos = new ArrayList<>(); + List states = new ArrayList<>(); + for (OutboundDispatchReadMapper.Binding binding : bindings) { + GateResult gate = gate(binding, pick, ocean, evidence, containerContributorGates); + inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate)); + inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(), + binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(), + binding.inboundStatus(), id(pick), status(pick))); + OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + if (state != null) states.add(state); + } + StateView visible = visibleStateForRead(states, bindings.size()); + ContainerGateResult containerGate = new ContainerGateResult( + inboundGates.stream().allMatch(value -> value.gate().passed()), List.copyOf(inboundGates), LocalDateTime.now()); + return new Aggregate(first.containerBookingId(), first.cabinetNo(), first.containerNo(), + first.loadingPlanId(), first.loadingPlanStatus(), id(ocean), status(ocean), id(truck), status(truck), + visible.status(), visible.version(), List.copyOf(inboundDtos), containerGate); } private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick, - OutboundDispatchReadMapper.IdStatus ocean, OutboundDispatchReadMapper.IdStatus truck, - OutboundDispatchReadMapper.Evidence evidence) { + OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence, + List containerContributorGates) { List items = new ArrayList<>(); items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()), binding.inboundStatus(), "LOADED")); @@ -128,14 +187,19 @@ public class OutboundDispatchService { status(pick), "COMPLETED")); items.add(item("OCEAN_BOOKING_RELEASED", "订舱已放舱且分配当前柜次", ocean != null && "RELEASED".equals(ocean.status()), status(ocean), "RELEASED")); - items.add(item("TRUCK_DEPARTED", "关联拖车已离仓", truck != null && TRUCK_READY.contains(truck.status()), - status(truck), "DEPARTED或COMPLETED")); boolean evidenceReady = evidence != null && evidence.evidenceCount() != null && evidence.evidenceCount() >= 5 && Objects.equals(evidence.validSeal(), 1); items.add(item("LOADING_EVIDENCE_COMPLETE", "四步照片与铅封完整", evidenceReady, evidence == null ? null : evidence.evidenceCount() + "个证据/有效铅封=" + evidence.validSeal(), "PHOTO_STEP_1..4和含铅封号的SEAL")); + items.addAll(containerContributorGates); + return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now()); + } + + private List contributorGates(OutboundDispatchReadMapper.Binding binding, + OutboundDispatchReadMapper.OceanBinding ocean) { + List items = new ArrayList<>(); var context = new OutboundDispatchGateContributor.Context(binding.tenantId(), binding.customerId(), binding.orderId(), binding.inboundId(), binding.containerBookingId(), binding.loadingPlanId(), id(ocean)); for (String code : REQUIRED_CONTRIBUTORS.stream().sorted().toList()) { @@ -151,77 +215,136 @@ public class OutboundDispatchService { items.add(item(code, contributorName(code), false, "ASSESSMENT_ERROR", "模块返回可放行状态")); } } - return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now()); + return List.copyOf(items); } - private Detail replay(long operatorId, String operation, String key, String hash, Query query) { + private Detail replay(long containerBookingId, long operatorId, String operation, String key, String hash) { if (repository.claim(operatorId, operation, key, hash)) return null; var existing = repository.operation(operatorId, operation, key); if (existing == null || !Objects.equals(existing.getRequestHash(), hash)) { throw conflict("Idempotency-Key已用于不同请求"); } if (!"SUCCESS".equals(existing.getStatus())) throw conflict("相同请求正在处理中"); - return detail(query); + return detail(containerBookingId); } - private OutboundDispatchEntity state(OutboundDispatchReadMapper.Binding binding) { - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); + private OutboundDispatchEntity stateLocked(OutboundDispatchReadMapper.Binding binding) { + OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); if (state != null) return state; state = new OutboundDispatchEntity(); - state.setId(IdWorker.getId()); state.setTenantId(binding.tenantId()); state.setOrderId(binding.orderId()); - state.setInboundId(binding.inboundId()); state.setContainerBookingId(binding.containerBookingId()); - state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); state.setVersion(0); - try { repository.insert(state); } - catch (DuplicateKeyException ex) { - state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); + state.setId(IdWorker.getId()); + state.setTenantId(binding.tenantId()); + state.setOrderId(binding.orderId()); + state.setInboundId(binding.inboundId()); + state.setContainerBookingId(binding.containerBookingId()); + state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); + state.setVersion(0); + try { + repository.insert(state); + } catch (DuplicateKeyException ex) { + state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); if (state == null) throw ex; } return state; } + private OutboundDispatchEntity existingStateLocked(OutboundDispatchReadMapper.Binding binding) { + OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + if (state == null) throw conflict("当前货柜尚未确认离仓"); + return state; + } + private void history(OutboundDispatchEntity state, String action, String from, String to, String remark, long operatorId, String operatorName) { OutboundDispatchHistoryEntity history = new OutboundDispatchHistoryEntity(); - history.setId(IdWorker.getId()); history.setDispatchId(state.getId()); history.setTenantId(state.getTenantId()); - history.setInboundId(state.getInboundId()); history.setContainerBookingId(state.getContainerBookingId()); - history.setAction(action); history.setFromStatus(from); history.setToStatus(to); history.setRemark(remark); - history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.history(history); + history.setId(IdWorker.getId()); + history.setDispatchId(state.getId()); + history.setTenantId(state.getTenantId()); + history.setInboundId(state.getInboundId()); + history.setContainerBookingId(state.getContainerBookingId()); + history.setAction(action); + history.setFromStatus(from); + history.setToStatus(to); + history.setRemark(remark); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + repository.history(history); } - private OutboundDispatchReadMapper.Binding single(Query query) { - requireSelector(query); - List bindings = repository.bindings(query); - if (bindings.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "未找到匹配的入库单与柜次关联"); - if (bindings.size() != 1) throw new BizException(ResultCode.BAD_REQUEST, "查询条件匹配多个出仓聚合,请同时指定inboundId和containerBookingId"); - return bindings.get(0); + private List bindings(long containerBookingId) { + return normalize(repository.bindings(containerBookingId)); } - private static void requireSelector(Query query) { - if (query == null || query.orderId() == null && query.inboundId() == null && query.containerBookingId() == null) { - throw new BizException(ResultCode.BAD_REQUEST, "orderId、inboundId、containerBookingId至少提供一个"); + private List lockedBindings(long containerBookingId) { + return normalize(repository.lockBindings(containerBookingId)); + } + + private static List normalize(List values) { + if (values == null || values.isEmpty()) { + throw new BizException(ResultCode.NOT_FOUND, "未找到当前货柜的真实入库绑定"); } + LinkedHashMap unique = new LinkedHashMap<>(); + for (OutboundDispatchReadMapper.Binding value : values) unique.putIfAbsent(value.inboundId(), value); + return List.copyOf(unique.values()); } + + private static OutboundDispatchReadMapper.Binding validateContainer(List bindings) { + OutboundDispatchReadMapper.Binding first = bindings.get(0); + boolean inconsistent = bindings.stream().anyMatch(binding -> + !Objects.equals(first.containerBookingId(), binding.containerBookingId()) + || !Objects.equals(first.loadingPlanId(), binding.loadingPlanId())); + if (inconsistent) throw conflict("货柜入库绑定存在跨柜次数据冲突"); + return first; + } + + private static StateView visibleStateForRead(List states, int bindingCount) { + if (states.isEmpty()) return new StateView(OutboundDispatchStatus.READY_FOR_DISPATCH.name(), 0); + if (states.size() != bindingCount) throw conflict("货柜出仓状态不完整,请联系管理员处理"); + return visibleState(states); + } + + private static StateView visibleState(List states) { + if (states.isEmpty()) throw conflict("货柜出仓状态不存在"); + String status = states.get(0).getStatus(); + Integer version = states.get(0).getVersion(); + if (states.stream().anyMatch(value -> !Objects.equals(status, value.getStatus()) + || !Objects.equals(version, value.getVersion()))) { + throw conflict("货柜出仓状态或版本不一致,请联系管理员处理"); + } + return new StateView(status, version); + } + private static void validateKey(String key) { if (key == null || key.isBlank() || key.length() > 128) { throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且长度不能超过128"); } } - private static String hash(String action, ActionRequest request) { - String source = action + "|" + request.inboundId() + "|" + request.containerBookingId() + "|" - + request.expectedVersion() + "|" + Objects.toString(request.remark(), ""); + + private static String hash(String action, long containerBookingId, ActionRequest request) { + String source = action + "|" + containerBookingId + "|" + request.expectedVersion() + "|" + + Objects.toString(request.remark(), ""); try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest); - } catch (Exception ex) { throw new IllegalStateException(ex); } + } catch (Exception ex) { + throw new IllegalStateException(ex); + } } + private static Long id(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.id(); } + private static Long id(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.id(); } private static String status(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.status(); } + private static String status(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.status(); } private static GateItem item(String code, String name, boolean pass, String actual, String requirement) { return new GateItem(code, name, pass, actual, requirement); } private static String contributorName(String code) { - return "CUSTOMS_CLEARANCE".equals(code) ? "报关协同完成" : "船期有效且未过期"; + return "报关协同完成"; } private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); } + + private record StateView(String status, Integer version) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index 6ef1ffe70..4b76ab3a0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -4,14 +4,14 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; import com.baiyue.firstleg.service.FirstlegScopeGuard; import io.swagger.v3.oas.annotations.Operation; @@ -41,7 +41,7 @@ public class PlatformCarrierScheduleQueryController { @Operation(summary = "查询六家船公司合规适配策略") @GetMapping("/carriers") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse> carriers(@AuthenticationPrincipal AuthenticatedUser user) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.carriers()); @@ -57,7 +57,7 @@ public class PlatformCarrierScheduleQueryController { } @GetMapping("/{queryId}") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) { FirstlegScopeGuard.platform(user, service.customerId(queryId)); @@ -65,8 +65,8 @@ public class PlatformCarrierScheduleQueryController { } @GetMapping("/{queryId}/history") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')") - public ApiResponse> history( + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") + public ApiResponse> history( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) { FirstlegScopeGuard.platform(user, service.customerId(queryId)); return ApiResponse.success(service.history(queryId)); @@ -83,18 +83,6 @@ public class PlatformCarrierScheduleQueryController { return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } - @PostMapping("/{queryId}/attempts") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:attempt')") - @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_ATTEMPT", resourceType = "CARRIER_SCHEDULE_QUERY", - resourceId = "#queryId", description = "执行船司官网查询策略") - public ApiResponse attempt(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long queryId, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody AttemptRequest request) { - FirstlegScopeGuard.platform(user, service.customerId(queryId)); - return ApiResponse.success(service.attempt(queryId, user.userId(), user.username(), key, request)); - } - @PostMapping("/{queryId}/manual-results") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY", @@ -106,4 +94,18 @@ public class PlatformCarrierScheduleQueryController { FirstlegScopeGuard.platform(user, service.customerId(queryId)); return ApiResponse.success(service.attachManualResult(queryId, user.userId(), user.username(), key, request)); } + + @PostMapping("/{queryId}/manual-nodes") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") + @Operation(summary = "人工补录预计或实际标准船节点", + description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。") + @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY", + resourceId = "#queryId", description = "人工补录船节点事实") + public ApiResponse manualNode(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long queryId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ManualNodeRequest request) { + FirstlegScopeGuard.platform(user, service.customerId(queryId)); + return ApiResponse.success(service.addManualNode(queryId, user.userId(), user.username(), key, request)); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java index 2b586c181..6b1c5c271 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java @@ -47,7 +47,7 @@ public class PlatformPortScheduleController { } @GetMapping("/{portScheduleId}") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long portScheduleId) { FirstlegScopeGuard.platform(user); @@ -55,7 +55,7 @@ public class PlatformPortScheduleController { } @GetMapping("/{portScheduleId}/history") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')") public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long portScheduleId) { FirstlegScopeGuard.platform(user); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleExceptionCode.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleExceptionCode.java new file mode 100644 index 000000000..e24dfb348 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleExceptionCode.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.portschedule.domain; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Locale; + +public enum CarrierScheduleExceptionCode { + CUSTOMS_HOLD, + TERMINAL_HOLD, + DOCUMENT_HOLD, + UNKNOWN_HOLD; + + public static CarrierScheduleExceptionCode require(String value) { + try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点异常:" + value); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java new file mode 100644 index 000000000..5ace71e11 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java @@ -0,0 +1,33 @@ +package com.baiyue.firstleg.portschedule.domain; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Locale; + +/** Provider-independent maritime milestones in monotonic business order. */ +public enum CarrierScheduleNode { + DEPARTED_WAREHOUSE(10), + AT_ORIGIN_PORT(20), + LOADED_ON_VESSEL(30), + DEPARTED_ORIGIN_PORT(40), + IN_TRANSIT(50), + ARRIVED_DESTINATION_PORT(60), + DISCHARGED_FROM_VESSEL(70), + PICKUP_READY(80), + PICKED_UP(90), + EMPTY_RETURNED(100); + + private final int sequence; + + CarrierScheduleNode(int sequence) { this.sequence = sequence; } + + public int sequence() { return sequence; } + + public static CarrierScheduleNode require(String value) { + try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点:" + value); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java index 07a06443d..4c5a90237 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java @@ -1,8 +1,5 @@ package com.baiyue.firstleg.portschedule.dto; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -19,9 +16,9 @@ public final class CarrierScheduleQueryDtos { public record Query(@Positive Long customerId, @Positive Long oceanBookingId, @Positive Long containerBookingId, @Size(max = 16) String carrierCode, @Size(max = 24) String status, Boolean expired, - @Size(max = 128) String keyword, @Min(1) long page, - @Min(1) @Max(200) long size) { - public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + @Size(max = 128) String keyword, @Min(1) Long page, + @Min(1) @Max(200) Long size) { + public Query { if (page == null || page == 0) page = 1L; if (size == null || size == 0) size = 20L; } } public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion, @@ -33,20 +30,71 @@ public final class CarrierScheduleQueryDtos { @NotBlank @Size(max = 128) String trackingNo, @Size(max = 1000) String remark) {} - public record AttemptRequest(@NotNull @Min(0) Integer expectedVersion, - @Size(max = 1000) String reason) {} - public record ManualResultRequest(@NotNull @Min(0) Integer expectedVersion, @NotNull @Positive Long portScheduleId, @NotBlank @Size(max = 1000) String reason) {} + public record ManualNodeRequest(@NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 32) String standardNode, + @NotNull LocalDateTime observedAt, + LocalDateTime estimatedAt, + LocalDateTime actualAt, + @Size(max = 32) String locationCode, + @Size(max = 128) String locationName, + @NotBlank @Size(max = 1000) String reason) {} + + public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode, + @NotBlank @Size(max = 128) String providerEventId, + @NotBlank @Size(max = 64) String providerNodeCode, + @NotBlank @Size(max = 32) String standardNode, + @NotNull LocalDateTime observedAt, + LocalDateTime estimatedAt, + LocalDateTime actualAt, + @Size(max = 32) String locationCode, + @Size(max = 128) String locationName, + @Size(max = 500) String sourceReference, + @NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {} + + public record ProviderExceptionRequest(@NotBlank @Size(max = 32) String providerCode, + @NotBlank @Size(max = 128) String providerEventId, + @NotBlank @Size(max = 128) String providerStatus, + @NotBlank @Size(max = 32) String exceptionCode, + boolean active, + @NotNull LocalDateTime observedAt, + @Size(max = 32) String locationCode, + @Size(max = 128) String locationName, + @Size(max = 500) String sourceReference, + @NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {} + public record CarrierDescriptor(String carrierCode, String carrierName, String officialUrl, String accessMode, boolean automated, boolean coverageGap, String coverageGapCode, String manualReason) {} - public record QueryView(CarrierScheduleQueryEntity query, boolean expired, - int maxAttempts, LocalDateTime nextAllowedAt) {} + public record Task(Long id, String queryNo, Long customerId, Long oceanBookingId, + Long containerBookingId, String carrierCode, String trackingType, String trackingNo, + String officialUrl, String accessMode, String status, String triggerSource, + String latestActualNode, LocalDateTime latestActualAt, + LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt, + Long resultPortScheduleId, String manualReason, String lastErrorCode, + String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {} - public record Detail(QueryView query, List attempts, - List histories) {} + public record QueryView(Task query, boolean expired) {} + + public record NodeFact(Long id, String providerCode, String providerEventId, + String providerNodeCode, String standardNode, + int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, + LocalDateTime actualAt, String locationCode, String locationName, + String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} + + public record ExceptionFact(Long id, String providerCode, String providerEventId, + String providerStatus, String exceptionCode, boolean active, + LocalDateTime observedAt, String locationCode, String locationName, + String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} + + public record History(Long id, String action, Integer fromVersion, Integer toVersion, String reason, + Long operatorId, String operatorName, LocalDateTime createdAt) {} + + /** Frontend detail intentionally excludes scheduler leases and raw query attempts. */ + public record Detail(QueryView query, List nodes, List exceptions, + List histories) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java new file mode 100644 index 000000000..e7fa6c220 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java @@ -0,0 +1,31 @@ +package com.baiyue.firstleg.portschedule.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +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 lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_carrier_schedule_exception_fact") +public class CarrierScheduleExceptionFactEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long queryId; + private String eventKey; + private String providerCode; + private String providerEventId; + private String providerStatus; + private String exceptionCode; + private Boolean active; + private LocalDateTime observedAt; + private String locationCode; + private String locationName; + private String sourceReference; + private String rawPayloadHash; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java new file mode 100644 index 000000000..8df45bac0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java @@ -0,0 +1,33 @@ +package com.baiyue.firstleg.portschedule.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +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 lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_carrier_schedule_node_fact") +public class CarrierScheduleNodeFactEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long queryId; + private String eventKey; + private String providerCode; + private String providerEventId; + private String providerNodeCode; + private String standardNode; + private Integer nodeSequence; + private LocalDateTime observedAt; + private LocalDateTime estimatedAt; + private LocalDateTime actualAt; + private String locationCode; + private String locationName; + private String sourceReference; + private String rawPayloadHash; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java index fd460a582..748aff208 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java @@ -26,6 +26,14 @@ public class CarrierScheduleQueryEntity { private String officialUrl; private String accessMode; private String status; + private String triggerSource; + private String latestStandardNode; + private LocalDateTime latestNodeObservedAt; + private LocalDateTime stoppedAt; + private String stopReason; + private String leaseOwner; + private LocalDateTime leaseUntil; + private Integer consecutiveFailures; private LocalDateTime windowStartedAt; private Integer attemptCount; private LocalDateTime nextAllowedAt; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleExceptionFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleExceptionFactMapper.java new file mode 100644 index 000000000..6032850bb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleExceptionFactMapper.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface CarrierScheduleExceptionFactMapper extends BaseMapper { + @Insert(""" + INSERT INTO fl_carrier_schedule_exception_fact + (id,query_id,event_key,provider_code,provider_event_id,provider_status,exception_code,active, + observed_at,location_code,location_name,source_reference,raw_payload_hash,created_at) + VALUES + (#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId}, + #{value.providerStatus},#{value.exceptionCode},#{value.active},#{value.observedAt}, + #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, + CURRENT_TIMESTAMP(3)) + ON DUPLICATE KEY UPDATE id=id + """) + int insertIfAbsent(@Param("value") CarrierScheduleExceptionFactEntity value); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java new file mode 100644 index 000000000..f20d62918 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface CarrierScheduleNodeFactMapper extends BaseMapper { + @Insert(""" + INSERT INTO fl_carrier_schedule_node_fact + (id,query_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at, + estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at) + VALUES + (#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode}, + #{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt}, + #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, + CURRENT_TIMESTAMP(3)) + ON DUPLICATE KEY UPDATE id=id + """) + int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index cc973ee0b..274bff0d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -9,6 +9,9 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.time.LocalDateTime; +import java.util.List; + @Mapper public interface CarrierScheduleQueryMapper extends BaseMapper { @Select(""" @@ -33,9 +36,42 @@ public interface CarrierScheduleQueryMapper extends BaseMapper selectDue(@Param("now") LocalDateTime now, @Param("limit") int limit); + + @Update(""" + UPDATE fl_carrier_schedule_query SET lease_owner=#{owner},lease_until=#{leaseUntil}, + updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED') + AND next_allowed_at IS NOT NULL AND next_allowed_at <= #{now} + AND (lease_until IS NULL OR lease_until < #{now}) + """) + int claimLease(@Param("id") long id, @Param("owner") String owner, + @Param("now") LocalDateTime now, @Param("leaseUntil") LocalDateTime leaseUntil); + @Update(""" UPDATE fl_carrier_schedule_query SET official_url=#{value.officialUrl},access_mode=#{value.accessMode},status=#{value.status}, + trigger_source=#{value.triggerSource},latest_standard_node=#{value.latestStandardNode}, + latest_node_observed_at=#{value.latestNodeObservedAt},stopped_at=#{value.stoppedAt}, + stop_reason=#{value.stopReason},lease_owner=#{value.leaseOwner},lease_until=#{value.leaseUntil}, + consecutive_failures=#{value.consecutiveFailures}, window_started_at=#{value.windowStartedAt},attempt_count=#{value.attemptCount}, next_allowed_at=#{value.nextAllowedAt},last_attempt_at=#{value.lastAttemptAt}, last_success_at=#{value.lastSuccessAt},source_updated_at=#{value.sourceUpdatedAt}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java index f92a10783..7df760374 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java @@ -9,14 +9,19 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryAttemptMapper; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryHistoryMapper; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryOperationMapper; +import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper; +import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleExceptionFactMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import java.util.List; +import java.time.LocalDateTime; @Repository public class CarrierScheduleQueryRepository { @@ -24,12 +29,21 @@ public class CarrierScheduleQueryRepository { @Resource private CarrierScheduleQueryAttemptMapper attempts; @Resource private CarrierScheduleQueryHistoryMapper histories; @Resource private CarrierScheduleQueryOperationMapper operations; + @Resource private CarrierScheduleNodeFactMapper nodes; + @Resource private CarrierScheduleExceptionFactMapper exceptions; public PageResult page(Query query) { return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query)); } public CarrierScheduleQueryEntity find(long id) { return queries.selectById(id); } public CarrierScheduleQueryEntity lock(long id) { return queries.lockById(id); } + public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) { + return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId); + } + public List due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); } + public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) { + return queries.claimLease(id, owner, now, leaseUntil) == 1; + } public void insert(CarrierScheduleQueryEntity value) { queries.insert(value); } public int updateState(CarrierScheduleQueryEntity value, int version, long operatorId, String operatorName) { return queries.updateState(value, version, operatorId, operatorName); @@ -48,6 +62,39 @@ public class CarrierScheduleQueryRepository { .orderByAsc(CarrierScheduleQueryHistoryEntity::getId)); } public void insert(CarrierScheduleQueryHistoryEntity value) { histories.insert(value); } + public List nodes(long id) { + return nodes.selectList(new LambdaQueryWrapper() + .eq(CarrierScheduleNodeFactEntity::getQueryId, id) + .orderByAsc(CarrierScheduleNodeFactEntity::getNodeSequence) + .orderByAsc(CarrierScheduleNodeFactEntity::getObservedAt) + .orderByAsc(CarrierScheduleNodeFactEntity::getId)); + } + public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; } + public CarrierScheduleNodeFactEntity node(long queryId, String eventKey) { + return nodes.selectOne(new LambdaQueryWrapper() + .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) + .eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey)); + } + public boolean hasActualNode(long queryId, String standardNode) { + return nodes.selectCount(new LambdaQueryWrapper() + .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) + .eq(CarrierScheduleNodeFactEntity::getStandardNode, standardNode) + .isNotNull(CarrierScheduleNodeFactEntity::getActualAt)) > 0; + } + public List exceptions(long id) { + return exceptions.selectList(new LambdaQueryWrapper() + .eq(CarrierScheduleExceptionFactEntity::getQueryId, id) + .orderByAsc(CarrierScheduleExceptionFactEntity::getObservedAt) + .orderByAsc(CarrierScheduleExceptionFactEntity::getId)); + } + public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) { + return exceptions.insertIfAbsent(value) == 1; + } + public CarrierScheduleExceptionFactEntity exception(long queryId, String eventKey) { + return exceptions.selectOne(new LambdaQueryWrapper() + .eq(CarrierScheduleExceptionFactEntity::getQueryId, queryId) + .eq(CarrierScheduleExceptionFactEntity::getEventKey, eventKey)); + } public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) { return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java index 1ddcd1d77..cd7ed02d5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -16,16 +17,39 @@ import java.util.Map; @Component public class CarrierScheduleAdapterRegistry { private static final Map DEFINITIONS = definitions(); + @Value("${baiyue.carrier-schedule.providers.matson-automated:false}") + private boolean matsonAutomated; + @Value("${baiyue.carrier-schedule.providers.hede-automated:false}") + private boolean hedeAutomated; + @Value("${baiyue.carrier-schedule.providers.sml-automated:false}") + private boolean smlAutomated; + @Value("${baiyue.carrier-schedule.providers.zim-official-api-enabled:false}") + private boolean zimOfficialApiEnabled; + @Value("${baiyue.carrier-schedule.providers.cosco-official-api-enabled:false}") + private boolean coscoOfficialApiEnabled; + @Value("${baiyue.carrier-schedule.providers.emc-automated:false}") + private boolean emcAutomated; public List descriptors() { - return DEFINITIONS.values().stream().map(Definition::descriptor).toList(); + return DEFINITIONS.values().stream().map(value -> value.descriptor(automated(value))).toList(); } public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) { Definition definition = required(carrierCode); + boolean automated = automated(definition); return new PreparedAttempt(definition.code(), definition.accessMode(), - officialUrl(definition, trackingType, trackingNo), "MANUAL_REQUIRED", - definition.coverageGapCode(), definition.manualReason()); + officialUrl(definition, trackingType, trackingNo), automated ? "READY" : "MANUAL_REQUIRED", + definition.coverageGapCode(), automated ? null : manualReason(definition)); + } + + /** Outbound confirmation is a physical fact and must not be blocked by provider coverage. */ + public PreparedAttempt prepareOutbound(String carrierCode, String trackingType, String trackingNo) { + String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT); + if (normalized.isEmpty() || "OTHER".equals(normalized)) { + return new PreparedAttempt("OTHER", "MANUAL", "", "MANUAL_REQUIRED", + "UNSUPPORTED_CARRIER", "船司未接入自动查询,需人工补录船节点"); + } + return prepare(normalized, trackingType, trackingNo); } public String normalize(String carrierCode) { @@ -44,9 +68,6 @@ public class CarrierScheduleAdapterRegistry { private static String officialUrl(Definition definition, String trackingType, String trackingNo) { String encoded = URLEncoder.encode(trackingNo.trim(), StandardCharsets.UTF_8); - if ("ZIM".equals(definition.code())) { - return definition.officialUrl() + "?consnumber=" + encoded; - } if ("EMC".equals(definition.code())) { String type = "CONTAINER_NO".equalsIgnoreCase(trackingType) ? "cntr" : "bl"; return definition.officialUrl() + "?c=" + encoded + "&t=" + type + "&url=EVERGREEN"; @@ -57,28 +78,57 @@ public class CarrierScheduleAdapterRegistry { private static Map definitions() { Map values = new LinkedHashMap<>(); add(values, new Definition("MATSON", "美森", "https://www.matson.com/shipment-tracking.html", - "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开交互表单,当前需人工查询并录入结果")); + "PUBLIC_WEB_PROXY", null, null)); add(values, new Definition("SML", "SML", "https://esvc.smlines.com/smline/CUP_HOM_3301.do?sessLocale=zh", - "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果")); - add(values, new Definition("ZIM", "ZIM", "https://www.zimchina.com/tools/track-a-shipment", - "HCAPTCHA", "HCAPTCHA_REQUIRED", "官网启用hCaptcha,禁止绕过验证码,必须人工查询")); - add(values, new Definition("COSCO", "COSCO", "https://elines.coscoshipping.com/ebusiness/", - "DYNAMIC_IFRAME", "DYNAMIC_IFRAME_REQUIRED", "官网使用动态页面/iframe,未发现稳定公开API,必须人工查询")); + "PUBLIC_FORM", null, null)); + add(values, new Definition("ZIM", "ZIM", "https://api.zim.com/", + "OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED", + "ZIM Cargo Tracking API 待配置订阅凭据及来源授权,启用前必须人工查询")); + add(values, new Definition("COSCO", "COSCO", "https://cop.lines.coscoshipping.com/copPortal/", + "OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED", + "COSCO Shipping Open Platform Cargo Tracking API 待注册订阅及凭据,启用前必须人工查询")); add(values, new Definition("EMC", "EMC", "https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do", "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开查询表单,当前需人工确认页面结果后录入")); add(values, new Definition("HEDE", "合德", "http://elines.hedehk.com/cargoDynamic", - "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果")); + "PUBLIC_HTML", null, null)); return Collections.unmodifiableMap(values); } private static void add(Map values, Definition value) { values.put(value.code(), value); } + private boolean automated(Definition definition) { + return switch (definition.code()) { + case "MATSON" -> matsonAutomated; + case "SML" -> smlAutomated; + case "ZIM" -> zimOfficialApiEnabled; + case "COSCO" -> coscoOfficialApiEnabled; + case "EMC" -> emcAutomated; + case "HEDE" -> hedeAutomated; + default -> false; + }; + } + + private static String manualReason(Definition definition) { + return definition.manualReason() == null + ? definition.name() + "自动查询当前未启用,需人工补录船节点" + : definition.manualReason(); + } + private record Definition(String code, String name, String officialUrl, String accessMode, String coverageGapCode, String manualReason) { - CarrierDescriptor descriptor() { - return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true, + CarrierDescriptor descriptor(boolean automated) { + return new CarrierDescriptor(code, name, officialUrl, accessMode, automated, !automated, coverageGapCode, manualReason); } + boolean matches(String value) { + return value.equals(code) || value.contains(code) || value.contains(name.toUpperCase(Locale.ROOT)) + || "MATSON".equals(code) && value.contains("美森") + || "SML".equals(code) && (value.contains("森罗") || value.contains("SM LINE")) + || "ZIM".equals(code) && value.contains("以星") + || "COSCO".equals(code) && (value.contains("中远") || value.contains("COSCO SHIPPING")) + || "EMC".equals(code) && (value.contains("长荣") || value.contains("EVERGREEN")) + || "HEDE".equals(code) && value.contains("合德"); + } } public record PreparedAttempt(String carrierCode, String accessMode, String officialUrl, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java new file mode 100644 index 000000000..5f949eece --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java @@ -0,0 +1,75 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +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.EventIdentifiers; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** Writes actual carrier node facts to the domestic transactional Outbox. */ +@Service +public class CarrierScheduleNodeOutboxService { + @Resource private InboundOutboxMapper outbox; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + private String scheduleZone = "Asia/Shanghai"; + + public void publish(CarrierScheduleQueryEntity query, CarrierScheduleNodeFactEntity fact, + String containerNo) { + if (fact.getActualAt() == null) return; + ZoneId zone = ZoneId.of(scheduleZone); + var payload = new FirstLegContainerShippingNodeRecordedV1( + string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()), + string(query.getContainerBookingId()), containerNo, fact.getStandardNode(), fact.getNodeSequence(), + fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(), + fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(), + fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash()); + JsonNode payloadNode = objectMapper.valueToTree(payload); + String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); + long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion()); + String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId(); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, + fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS, + string(query.getCustomerId()), "CONTAINER_BOOKING", string(query.getContainerBookingId()), + aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null, + payloadNode, payloadHash).requireValid(); + + InboundOutboxEntity row = new InboundOutboxEntity(); + row.setId(IdWorker.getId()); row.setTenantId(query.getCustomerId()); + row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId()); + row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1); + row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); + row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0); + row.setCreatedAt(LocalDateTime.now()); + try { outbox.insert(row); } catch (DuplicateKeyException ignored) { } + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { + throw new BizException(ResultCode.INTERNAL_ERROR, "船节点交接事件序列化失败"); + } + } + + private static String string(Long value) { return value == null ? null : String.valueOf(value); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java new file mode 100644 index 000000000..3f746a807 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java @@ -0,0 +1,100 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient; +import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient.QueryRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.UUID; + +@Component +public class CarrierScheduleQueryScheduler { + private final CarrierScheduleQueryRepository repository; + private final CarrierScheduleQueryService service; + private final CarrierScheduleIntegrationClient integration; + private final String workerId = UUID.randomUUID().toString(); + private Clock clock = Clock.systemDefaultZone(); + + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + @Value("${baiyue.carrier-schedule.scheduler.batch-size:20}") private int batchSize = 20; + @Value("${baiyue.carrier-schedule.scheduler.max-tasks-per-trigger:2000}") + private int maxTasksPerTrigger = 2000; + @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") private String scheduleZone = "Asia/Shanghai"; + @Value("${baiyue.carrier-schedule.scheduler.lease-duration:PT5M}") + private Duration leaseDuration = Duration.ofMinutes(5); + + public CarrierScheduleQueryScheduler(CarrierScheduleQueryRepository repository, + CarrierScheduleQueryService service, + CarrierScheduleIntegrationClient integration) { + this.repository = repository; this.service = service; this.integration = integration; + } + + @Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.scan-fixed-delay:PT1M}", + initialDelayString = "${baiyue.carrier-schedule.scheduler.scan-initial-delay:PT10S}") + public void poll() { + LocalDateTime triggerAt = LocalDateTime.ofInstant(clock.instant(), ZoneId.of(scheduleZone)); + int batch = Math.max(1, Math.min(batchSize, 100)); + int safetyLimit = Math.max(1, maxTasksPerTrigger); + int scanned = 0; + while (scanned < safetyLimit) { + List candidates = repository.due(triggerAt, + Math.min(batch, safetyLimit - scanned)); + if (candidates.isEmpty()) return; + for (CarrierScheduleQueryEntity candidate : candidates) { + execute(candidate, triggerAt); scanned++; + if (scanned >= safetyLimit) return; + } + if (candidates.size() < batch) return; + } + } + + /** Executes only after this worker wins the database lease. */ + void execute(CarrierScheduleQueryEntity candidate, LocalDateTime now) { + if (!repository.claimLease(candidate.getId(), workerId, now, now.plus(leaseDuration))) return; + if (service.deferMissedNormalSlot(candidate.getId(), now)) return; + try { + var response = integration.query(internalToken, + new QueryRequest(candidate.getCarrierCode(), candidate.getTrackingType(), candidate.getTrackingNo())); + if (response == null || !response.successful() || response.data() == null) { + service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "EMPTY_PROVIDER_RESPONSE", + "轨迹供应商返回空响应"); + return; + } + var result = response.data(); + if ("SUCCESS".equals(result.outcome())) { + List observations = result.observations() == null + ? List.of() : result.observations(); + for (var observation : observations) { + service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest( + result.providerCode(), observation.providerEventId(), observation.providerNodeCode(), + observation.standardNode(), observation.observedAt(), observation.estimatedAt(), + observation.actualAt(), observation.locationCode(), observation.locationName(), + observation.sourceReference(), observation.rawPayloadHash())); + } + List exceptions = result.exceptions() == null + ? List.of() : result.exceptions(); + for (var exception : exceptions) { + service.applyProviderException(candidate.getId(), new ProviderExceptionRequest( + result.providerCode(), exception.providerEventId(), exception.providerStatus(), + exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(), + exception.locationName(), exception.sourceReference(), exception.rawPayloadHash())); + } + } + service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(), + result.errorMessage()); + } catch (RuntimeException exception) { + service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "PROVIDER_CALL_FAILED", + exception.getClass().getSimpleName()); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index c5df3da71..417ab2d60 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -5,17 +5,28 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; -import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.NodeFact; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ExceptionFact; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Task; +import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode; +import com.baiyue.firstleg.portschedule.domain.CarrierScheduleExceptionCode; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; +import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; @@ -24,6 +35,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -32,6 +45,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Clock; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.HexFormat; import java.util.List; import java.util.Locale; @@ -40,14 +54,16 @@ import java.util.Set; @Service public class CarrierScheduleQueryService { - static final int MAX_ATTEMPTS = 3; - static final int WINDOW_DAYS = 3; + static final int MAX_SLOT_ATTEMPTS = 3; private static final Set TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO"); @Resource private CarrierScheduleQueryRepository repository; @Resource private PortScheduleRepository portSchedules; @Resource private CarrierScheduleAdapterRegistry adapters; + @Resource private CarrierScheduleNodeOutboxService nodeOutbox; @Resource private ObjectMapper objectMapper; + @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + private String scheduleZone = "Asia/Shanghai"; private Clock clock = Clock.systemDefaultZone(); public List carriers() { return adapters.descriptors(); } @@ -61,13 +77,275 @@ public class CarrierScheduleQueryService { public Detail detail(long id) { return detail(required(id)); } - public List history(long id) { + public List history(long id) { required(id); - return repository.histories(id); + return repository.histories(id).stream().map(this::history).toList(); } public long customerId(long id) { return required(id).getCustomerId(); } + /** Idempotently starts the canonical tracking task after domestic outbound confirmation. */ + @Transactional + public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId, + long containerBookingId, + long operatorId, String operatorName) { + CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId, + containerBookingId); + if (existing != null) return existing; + OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId); + ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); + if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在"); + validateLinks(customerId, oceanBookingId, containerBookingId); + String trackingType; + String trackingNo; + if (StringUtils.hasText(container.getContainerNo())) { + trackingType = "CONTAINER_NO"; trackingNo = container.getContainerNo().trim(); + } else if (StringUtils.hasText(booking.getBillOfLadingNo())) { + trackingType = "BILL_OF_LADING"; trackingNo = booking.getBillOfLadingNo().trim(); + } else if (StringUtils.hasText(booking.getBookingNo())) { + trackingType = "BOOKING_NO"; trackingNo = booking.getBookingNo().trim(); + } else { + throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一"); + } + PreparedAttempt strategy = adapters.prepareOutbound(booking.getCarrierCode(), trackingType, trackingNo); + LocalDateTime now = now(); + CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); + value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId()); + value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId); + value.setContainerBookingId(containerBookingId); value.setCarrierCode(strategy.carrierCode()); + value.setTrackingType(trackingType); value.setTrackingNo(trackingNo); + value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); + value.setStatus(strategy.status()); value.setTriggerSource("OUTBOUND_CONFIRM"); + value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name()); + value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0); + value.setConsecutiveFailures(0); value.setNextAllowedAt("MANUAL".equals(strategy.accessMode()) + ? null : CarrierScheduleQuerySlots.nextSlot(now)); + value.setManualReason(outboundManualReason(strategy, booking)); + value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); + String persistenceStage = "QUERY_TASK"; + try { + try { repository.insert(value); } + catch (DuplicateKeyException exception) { + CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId, + containerBookingId); + if (concurrent != null) return concurrent; + throw exception; + } + persistenceStage = "NODE_FACT"; + CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", + CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", + sha256("OUTBOUND_CONFIRM|" + containerBookingId)); + if (departed != null) { + persistenceStage = "CROSS_REGION_OUTBOX"; + nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); + } + persistenceStage = "QUERY_HISTORY"; + addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value, + operatorId, operatorName); + } catch (DataIntegrityViolationException exception) { + throw conflict("船节点持久化契约与当前服务不一致(" + persistenceStage + + "),请确认OTHER/MANUAL约束及节点事实表已初始化"); + } + return value; + } + + private static String outboundManualReason(PreparedAttempt strategy, OceanBookingEntity booking) { + if (!"MANUAL_REQUIRED".equals(strategy.status())) return null; + String reason = strategy.manualReason(); + if (!"MANUAL".equals(strategy.accessMode())) return reason; + return reason + ";carrierCode=" + Objects.toString(booking.getCarrierCode(), "") + + ";shippingCompanyId=" + booking.getShippingCompanyId() + + ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), ""); + } + + @Transactional + public Detail applyProviderObservation(long id, ProviderObservationRequest request) { + CarrierScheduleQueryEntity value = requiredLocked(id); + CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); + CarrierScheduleQueryEntity before = copy(value); + CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(), + request.providerNodeCode(), node, + request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), + request.locationName(), request.sourceReference(), request.rawPayloadHash()); + if (fact == null) { + return detail(value); + } + boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), + 0L, "carrier-scheduler"); + CarrierScheduleQueryEntity after = advanced ? required(id) : value; + nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId())); + addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before, + after, 0L, "carrier-scheduler"); + return detail(after); + } + + @Transactional + public Detail applyProviderException(long id, ProviderExceptionRequest request) { + CarrierScheduleQueryEntity value = requiredLocked(id); + CarrierScheduleExceptionCode code = CarrierScheduleExceptionCode.require(request.exceptionCode()); + if (!addException(id, request.providerCode(), request.providerEventId(), request.providerStatus(), code, + request.active(), request.observedAt(), request.locationCode(), request.locationName(), + request.sourceReference(), request.rawPayloadHash())) { + return detail(value); + } + addHistory(id, "EXCEPTION_OBSERVED", value.getVersion(), value.getVersion(), code.name(), value, value, + 0L, "carrier-scheduler"); + return detail(value); + } + + @Transactional + public Detail addManualNode(long id, long operatorId, String operatorName, String key, + ManualNodeRequest request) { + String operation = "MANUAL_NODE"; + Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request)); + if (replay != null) return replay; + if (request.estimatedAt() == null && request.actualAt() == null) { + throw badRequest("estimatedAt和actualAt至少提供一个"); + } + CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion()); + CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); + requireManualActualAdvance(value, node, request.actualAt()); + CarrierScheduleQueryEntity before = copy(value); + String providerEventId = sha256("MANUAL|" + id + '|' + key); + CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node, + request.observedAt(), + request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), + "MANUAL:" + operatorId, hash(operation, id, request)); + if (fact == null) { + return complete(id, operatorId, key, detail(value)); + } + boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), + operatorId, operatorName); + if (!advanced && repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) { + throw concurrent(); + } + CarrierScheduleQueryEntity after = required(id); + nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId())); + addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after, + operatorId, operatorName); + return complete(id, operatorId, key, detail(after)); + } + + private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node, + LocalDateTime actualAt) { + if (actualAt == null || value.getLatestStandardNode() == null) return; + CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode()); + if (node.sequence() <= current.sequence()) { + throw conflict("人工实际节点必须严格晚于当前最新实际节点"); + } + } + + private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node, + LocalDateTime actualAt, LocalDateTime observedAt, + long operatorId, String operatorName) { + if (actualAt == null || "STOPPED".equals(value.getStatus())) return false; + CarrierScheduleNode current = value.getLatestStandardNode() == null ? null + : CarrierScheduleNode.require(value.getLatestStandardNode()); + boolean advances = current == null || node.sequence() > current.sequence() + || node.sequence() == current.sequence() && (value.getLatestNodeObservedAt() == null + || actualAt.isAfter(value.getLatestNodeObservedAt())); + if (advances) { + value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(actualAt); + value.setLastSuccessAt(now()); value.setSourceUpdatedAt(observedAt); + value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null); + if (node == CarrierScheduleNode.EMPTY_RETURNED) { + value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason("已还柜"); + value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null); + } else { + value.setStatus("READY"); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now())); + } + if (repository.updateState(value, value.getVersion(), operatorId, operatorName) != 1) throw concurrent(); + return true; + } + return false; + } + + public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) { + CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId, + containerBookingId); + return query != null && repository.hasActualNode(query.getId(), CarrierScheduleNode.DEPARTED_WAREHOUSE.name()); + } + + @Transactional + public boolean deferMissedNormalSlot(long id, LocalDateTime scanAt) { + CarrierScheduleQueryEntity value = requiredLocked(id); + if ("STOPPED".equals(value.getStatus())) return false; + int attempts = value.getAttemptCount() == null ? 0 : value.getAttemptCount(); + boolean staleRetry = attempts > 0 && value.getWindowStartedAt() != null + && !scanAt.isBefore(CarrierScheduleQuerySlots.nextSlot(value.getWindowStartedAt())); + if (staleRetry) { + LocalDateTime currentSlot = CarrierScheduleQuerySlots.fixedSlotInCurrentMinute(scanAt); + if (currentSlot != null) { + value.setNextAllowedAt(currentSlot); value.setWindowStartedAt(currentSlot); + value.setAttemptCount(0); value.setConsecutiveFailures(0); + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + return false; + } + } else if (!CarrierScheduleQuerySlots.isMissedNormalSlot( + value.getNextAllowedAt(), value.getAttemptCount(), scanAt)) return false; + value.setLeaseOwner(null); value.setLeaseUntil(null); + value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(scanAt)); + value.setWindowStartedAt(value.getNextAllowedAt()); + value.setAttemptCount(0); value.setConsecutiveFailures(0); + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + return true; + } + + @Transactional + public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) { + CarrierScheduleQueryEntity value = requiredLocked(id); + int slotAttemptNo = Math.min(MAX_SLOT_ATTEMPTS, increment(value.getAttemptCount())); + insertAttempt(value, slotAttemptNo, outcome, errorCode, errorMessage); + if ("STOPPED".equals(value.getStatus())) return; + CarrierScheduleQueryEntity before = copy(value); + LocalDateTime now = now(); + value.setLeaseOwner(null); value.setLeaseUntil(null); value.setLastAttemptAt(now); + value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode)); + value.setLastErrorMessage(trim(errorMessage)); + LocalDateTime nextFixedSlot = CarrierScheduleQuerySlots.nextSlot(now); + if ("RETRYABLE_FAILURE".equals(outcome)) { + value.setStatus("READY"); + value.setConsecutiveFailures(slotAttemptNo); + LocalDateTime retryAt = now.plusMinutes(15L << Math.max(0, slotAttemptNo - 1)); + if (slotAttemptNo < MAX_SLOT_ATTEMPTS && retryAt.isBefore(nextFixedSlot)) { + if (slotAttemptNo == 1 || value.getWindowStartedAt() == null) { + value.setWindowStartedAt(value.getNextAllowedAt()); + } + value.setAttemptCount(slotAttemptNo); value.setNextAllowedAt(retryAt); + } else { + resetForNextFixedSlot(value, nextFixedSlot); + } + } else { + value.setStatus("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome) + ? "MANUAL_REQUIRED" : "READY"); + resetForNextFixedSlot(value, nextFixedSlot); + } + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + CarrierScheduleQueryEntity after = required(id); + addHistory(id, "PROVIDER_QUERY", before.getVersion(), after.getVersion(), outcome, before, after, 0L, + "carrier-scheduler"); + } + + private static void resetForNextFixedSlot(CarrierScheduleQueryEntity value, LocalDateTime nextFixedSlot) { + value.setConsecutiveFailures(0); value.setAttemptCount(0); + value.setWindowStartedAt(nextFixedSlot); value.setNextAllowedAt(nextFixedSlot); + } + + private void insertAttempt(CarrierScheduleQueryEntity value, int slotAttemptNo, String outcome, + String errorCode, String errorMessage) { + CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity(); + attempt.setId(IdWorker.getId()); attempt.setQueryId(value.getId()); + attempt.setWindowAttemptNo(slotAttemptNo); + attempt.setCarrierCode(value.getCarrierCode()); attempt.setAccessMode(value.getAccessMode()); + attempt.setOfficialUrl(value.getOfficialUrl()); attempt.setAttemptStatus(outcome); + boolean manual = "MANUAL_REQUIRED".equals(outcome); + attempt.setCoverageGap(manual); attempt.setCoverageGapCode(manual ? trim(errorCode) : null); + attempt.setManualReason(manual ? trim(errorMessage) : null); attempt.setErrorCode(trim(errorCode)); + attempt.setErrorMessage(trim(errorMessage)); attempt.setOperatorId(0L); + attempt.setOperatorName("carrier-scheduler"); repository.insert(attempt); + } + @Transactional public Detail create(long operatorId, String operatorName, String key, CreateRequest request) { String operation = "CREATE"; @@ -84,8 +362,10 @@ public class CarrierScheduleQueryService { value.setContainerBookingId(request.containerBookingId()); value.setCarrierCode(carrierCode); value.setTrackingType(trackingType); value.setTrackingNo(request.trackingNo().trim()); value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); - value.setStatus("READY"); value.setWindowStartedAt(now); value.setAttemptCount(0); - value.setNextAllowedAt(now); value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark())); + value.setStatus("READY"); value.setTriggerSource("MANUAL"); + value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0); + value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); + value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark())); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); try { repository.insert(value); } @@ -94,51 +374,6 @@ public class CarrierScheduleQueryService { return complete(value.getId(), operatorId, key, detail(value)); } - @Transactional - public Detail attempt(long id, long operatorId, String operatorName, String key, AttemptRequest request) { - String operation = "ATTEMPT"; - Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request)); - if (replay != null) return replay; - CarrierScheduleQueryEntity before = requiredLockedVersion(id, request.expectedVersion()); - LocalDateTime now = now(); - LocalDateTime windowStart = before.getWindowStartedAt(); - if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) { - windowStart = now; - before.setAttemptCount(0); - } - if (before.getLastSuccessAt() != null && before.getNextAllowedAt() != null - && now.isBefore(before.getNextAllowedAt())) { - throw conflict("船期查询每3天执行一次,下次允许时间:" + before.getNextAllowedAt()); - } - int attemptCount = count(before.getAttemptCount()); - LocalDateTime windowEnd = windowStart.plusDays(WINDOW_DAYS); - if (attemptCount >= MAX_ATTEMPTS && now.isBefore(windowEnd)) { - throw conflict("当前3天窗口最多查询3次,下次允许时间:" + windowEnd); - } - PreparedAttempt strategy = adapters.prepare(before.getCarrierCode(), before.getTrackingType(), before.getTrackingNo()); - CarrierScheduleQueryEntity snapshot = copy(before); - int nextCount = attemptCount + 1; - before.setOfficialUrl(strategy.officialUrl()); before.setAccessMode(strategy.accessMode()); - before.setStatus(strategy.status()); before.setWindowStartedAt(windowStart); before.setAttemptCount(nextCount); - before.setLastAttemptAt(now); before.setLastAttemptStatus(strategy.status()); - before.setManualReason(strategy.manualReason()); before.setLastErrorCode(strategy.coverageGapCode()); - before.setLastErrorMessage(strategy.manualReason()); - before.setNextAllowedAt(nextCount >= MAX_ATTEMPTS ? windowEnd : now); - if (repository.updateState(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); - CarrierScheduleQueryEntity after = required(id); - CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity(); - attempt.setId(IdWorker.getId()); attempt.setQueryId(id); attempt.setWindowAttemptNo(nextCount); - attempt.setCarrierCode(after.getCarrierCode()); attempt.setAccessMode(strategy.accessMode()); - attempt.setOfficialUrl(strategy.officialUrl()); attempt.setAttemptStatus(strategy.status()); - attempt.setCoverageGap(true); attempt.setCoverageGapCode(strategy.coverageGapCode()); - attempt.setManualReason(strategy.manualReason()); attempt.setErrorCode(strategy.coverageGapCode()); - attempt.setErrorMessage(strategy.manualReason()); attempt.setOperatorId(operatorId); attempt.setOperatorName(operatorName); - repository.insert(attempt); - addHistory(id, operation, snapshot.getVersion(), after.getVersion(), request.reason(), snapshot, after, - operatorId, operatorName); - return complete(id, operatorId, key, detail(after)); - } - @Transactional public Detail attachManualResult(long id, long operatorId, String operatorName, String key, ManualResultRequest request) { @@ -155,10 +390,11 @@ public class CarrierScheduleQueryService { } CarrierScheduleQueryEntity before = copy(value); LocalDateTime now = now(); - value.setStatus("SUCCESS"); value.setResultPortScheduleId(schedule.getId()); + value.setStatus("READY"); value.setResultPortScheduleId(schedule.getId()); value.setLastSuccessAt(now); value.setSourceUpdatedAt(schedule.getObservedAt()); - value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); - value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED"); + value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); + value.setWindowStartedAt(value.getNextAllowedAt()); value.setAttemptCount(0); + value.setConsecutiveFailures(0); value.setLastAttemptStatus("MANUAL_COMPLETED"); value.setManualReason(trim(request.reason())); value.setLastErrorCode(null); value.setLastErrorMessage(null); if (repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); CarrierScheduleQueryEntity after = required(id); @@ -212,13 +448,91 @@ public class CarrierScheduleQueryService { } private Detail detail(CarrierScheduleQueryEntity value) { - return new Detail(view(value, now()), repository.attempts(value.getId()), repository.histories(value.getId())); + return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(), + repository.exceptions(value.getId()).stream().map(this::exception).toList(), + repository.histories(value.getId()).stream().map(this::history).toList()); + } + + private NodeFact node(CarrierScheduleNodeFactEntity value) { + return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(), + value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(), + value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), + value.getRawPayloadHash(), value.getCreatedAt()); + } + + private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) { + return new ExceptionFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), + value.getProviderStatus(), value.getExceptionCode(), Boolean.TRUE.equals(value.getActive()), + value.getObservedAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), + value.getRawPayloadHash(), value.getCreatedAt()); + } + + private History history(CarrierScheduleQueryHistoryEntity value) { + return new History(value.getId(), value.getAction(), value.getFromVersion(), value.getToVersion(), + value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); + } + + private CarrierScheduleNodeFactEntity addNode(long queryId, String providerCode, String providerEventId, + String providerNodeCode, + CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt, + LocalDateTime actualAt, String locationCode, String locationName, + String sourceReference, String rawPayloadHash) { + CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity(); + fact.setId(IdWorker.getId()); fact.setQueryId(queryId); + fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); + fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT)); + fact.setProviderEventId(providerEventId.trim()); + fact.setProviderNodeCode(providerNodeCode.trim()); fact.setStandardNode(node.name()); + fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); + fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); + fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); + if (repository.insertIfAbsent(fact)) return fact; + CarrierScheduleNodeFactEntity existing = repository.node(queryId, fact.getEventKey()); + if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) { + throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实"); + } + return null; + } + + private boolean addException(long queryId, String providerCode, String providerEventId, + String providerStatus, CarrierScheduleExceptionCode code, boolean active, + LocalDateTime observedAt, String locationCode, String locationName, + String sourceReference, String rawPayloadHash) { + CarrierScheduleExceptionFactEntity fact = new CarrierScheduleExceptionFactEntity(); + fact.setId(IdWorker.getId()); fact.setQueryId(queryId); + fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); + fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT)); + fact.setProviderEventId(providerEventId.trim()); fact.setProviderStatus(providerStatus.trim()); + fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt); + fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); + fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash); + if (repository.insertIfAbsent(fact)) return true; + CarrierScheduleExceptionFactEntity existing = repository.exception(queryId, fact.getEventKey()); + if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) { + throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有异常事实"); + } + return false; + } + + private String requireContainerNo(long containerBookingId) { + ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); + if (container == null || !StringUtils.hasText(container.getContainerNo())) { + throw conflict("跨区船节点交接要求柜次具备柜号"); + } + return container.getContainerNo().trim().toUpperCase(Locale.ROOT); } private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) { boolean expired = value.getLastSuccessAt() == null || value.getExpiresAt() == null || !value.getExpiresAt().isAfter(now); - return new QueryView(value, expired, MAX_ATTEMPTS, value.getNextAllowedAt()); + Task task = new Task(value.getId(), value.getQueryNo(), value.getCustomerId(), value.getOceanBookingId(), + value.getContainerBookingId(), value.getCarrierCode(), value.getTrackingType(), value.getTrackingNo(), + value.getOfficialUrl(), value.getAccessMode(), value.getStatus(), value.getTriggerSource(), + value.getLatestStandardNode(), value.getLatestNodeObservedAt(), value.getLastSuccessAt(), + value.getSourceUpdatedAt(), value.getExpiresAt(), value.getResultPortScheduleId(), value.getManualReason(), + value.getLastErrorCode(), value.getLastErrorMessage(), value.getStoppedAt(), value.getStopReason(), + value.getVersion()); + return new QueryView(task, expired); } private CarrierScheduleQueryEntity required(long id) { @@ -234,6 +548,12 @@ public class CarrierScheduleQueryService { return value; } + private CarrierScheduleQueryEntity requiredLocked(long id) { + CarrierScheduleQueryEntity value = repository.lock(id); + if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司查询任务不存在"); + return value; + } + private static CarrierScheduleQueryEntity copy(CarrierScheduleQueryEntity source) { CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(source.getId()); value.setQueryNo(source.getQueryNo()); value.setCustomerId(source.getCustomerId()); @@ -241,6 +561,10 @@ public class CarrierScheduleQueryService { value.setCarrierCode(source.getCarrierCode()); value.setTrackingType(source.getTrackingType()); value.setTrackingNo(source.getTrackingNo()); value.setOfficialUrl(source.getOfficialUrl()); value.setAccessMode(source.getAccessMode()); value.setStatus(source.getStatus()); + value.setTriggerSource(source.getTriggerSource()); value.setLatestStandardNode(source.getLatestStandardNode()); + value.setLatestNodeObservedAt(source.getLatestNodeObservedAt()); value.setStoppedAt(source.getStoppedAt()); + value.setStopReason(source.getStopReason()); value.setLeaseOwner(source.getLeaseOwner()); + value.setLeaseUntil(source.getLeaseUntil()); value.setConsecutiveFailures(source.getConsecutiveFailures()); value.setWindowStartedAt(source.getWindowStartedAt()); value.setAttemptCount(source.getAttemptCount()); value.setNextAllowedAt(source.getNextAllowedAt()); value.setLastAttemptAt(source.getLastAttemptAt()); value.setLastSuccessAt(source.getLastSuccessAt()); value.setSourceUpdatedAt(source.getSourceUpdatedAt()); @@ -259,6 +583,12 @@ public class CarrierScheduleQueryService { } catch (Exception ex) { throw new IllegalStateException("无法计算船司查询请求摘要", ex); } } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + private String json(Object value) { if (value == null) return null; try { return objectMapper.writeValueAsString(value); } @@ -273,8 +603,9 @@ public class CarrierScheduleQueryService { return normalized; } - private LocalDateTime now() { return LocalDateTime.now(clock); } - private static int count(Integer value) { return value == null ? 0 : value; } + private LocalDateTime now() { return LocalDateTime.ofInstant(clock.instant(), ZoneId.of(scheduleZone)); } + private static int increment(Integer value) { return value == null || value < 0 ? 1 + : value == Integer.MAX_VALUE ? Integer.MAX_VALUE : value + 1; } private static void validateKey(String key) { if (!StringUtils.hasText(key) || key.length() > 128) throw badRequest("Idempotency-Key不能为空且不能超过128个字符"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java new file mode 100644 index 000000000..cc0a4d4f2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java @@ -0,0 +1,44 @@ +package com.baiyue.firstleg.portschedule.service; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +public final class CarrierScheduleQuerySlots { + private static final LocalTime MORNING = LocalTime.of(9, 0); + private static final LocalTime NOON = LocalTime.of(12, 30); + private static final LocalTime AFTERNOON = LocalTime.of(16, 0); + + private CarrierScheduleQuerySlots() { } + + /** Returns the first fixed slot strictly after {@code now}. Exact slot boundaries advance. */ + public static LocalDateTime nextSlot(LocalDateTime now) { + if (now.toLocalTime().isBefore(MORNING)) return now.toLocalDate().atTime(MORNING); + if (now.toLocalTime().isBefore(NOON)) return now.toLocalDate().atTime(NOON); + if (now.toLocalTime().isBefore(AFTERNOON)) return now.toLocalDate().atTime(AFTERNOON); + return now.toLocalDate().plusDays(1).atTime(MORNING); + } + + public static boolean isFixedSlot(LocalDateTime value) { + if (value == null || value.getSecond() != 0 || value.getNano() != 0) return false; + return value.toLocalTime().equals(MORNING) || value.toLocalTime().equals(NOON) + || value.toLocalTime().equals(AFTERNOON); + } + + public static boolean isWithinFixedSlotMinute(LocalDateTime value) { + if (value == null) return false; + LocalTime minute = value.toLocalTime().withSecond(0).withNano(0); + return minute.equals(MORNING) || minute.equals(NOON) || minute.equals(AFTERNOON); + } + + public static LocalDateTime fixedSlotInCurrentMinute(LocalDateTime value) { + return isWithinFixedSlotMinute(value) ? value.withSecond(0).withNano(0) : null; + } + + /** A normal slot is executable only during its scheduled minute; technical retry times are unaffected. */ + public static boolean isMissedNormalSlot(LocalDateTime nextAllowedAt, Integer attemptCount, + LocalDateTime scanAt) { + return (attemptCount == null || attemptCount == 0) && isFixedSlot(nextAllowedAt) + && !isWithinFixedSlotMinute(scanAt) + && !scanAt.isBefore(nextAllowedAt.plusMinutes(1)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java similarity index 93% rename from firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java rename to firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java index f86961189..8b708f74d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java @@ -1,9 +1,10 @@ -package com.baiyue.firstleg.mapper; +package com.baiyue.firstleg.pricebookquery.adapter.out.persistence; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -12,7 +13,7 @@ import java.util.List; /** Dedicated read model for merchant channel selection and reference estimates. */ @Mapper -public interface PriceBookQueryMapper { +public interface PriceBookQueryMapper extends PriceBookQueryReader { @Select(""" SELECT channel_code AS channelCode, inbound_code_prefix AS inboundCodePrefix, overseas_inbound_code_prefix AS overseasInboundCodePrefix diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java new file mode 100644 index 000000000..094003ec2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java @@ -0,0 +1,45 @@ +package com.baiyue.firstleg.pricebookquery.application; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PriceBookQueryService implements PriceBookQueryUseCase { + @Resource private PriceBookQueryReader reader; + + @Override public Long activeVersionId() { return reader.findActiveVersionId(); } + @Override public List channels(long versionId, long originWarehouseId) { + return reader.findChannels(versionId, originWarehouseId); + } + @Override public List channelTaxModes(long versionId, long originWarehouseId) { + return reader.findChannelTaxModes(versionId, originWarehouseId); + } + @Override public List fbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode) { + return reader.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode); + } + @Override public List overseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode) { + return reader.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId, + pricingTaxMode); + } + @Override public List regionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode) { + return reader.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode); + } + @Override public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) { + return channels(versionId, originWarehouseId).stream() + .anyMatch(channel -> channelCode.equals(channel.getChannelCode())); + } + @Override public ChannelPrefixRow channelPrefix(String channelCode) { + return reader.findChannelPrefix(channelCode); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java similarity index 96% rename from firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java rename to firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java index dc8395fc6..0ca9c0b8a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java @@ -1,4 +1,4 @@ -package com.baiyue.firstleg.repository; +package com.baiyue.firstleg.pricebookquery.application.model; import lombok.Getter; import lombok.Setter; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java new file mode 100644 index 000000000..980c86ad7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.pricebookquery.application.port.in; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; + +import java.util.List; + +public interface PriceBookQueryUseCase { + Long activeVersionId(); + List channels(long versionId, long originWarehouseId); + List channelTaxModes(long versionId, long originWarehouseId); + List fbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode); + List overseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode); + List regionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode); + boolean channelAvailable(long versionId, long originWarehouseId, String channelCode); + ChannelPrefixRow channelPrefix(String channelCode); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java new file mode 100644 index 000000000..04d852aab --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.pricebookquery.application.port.out; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; + +import java.util.List; + +public interface PriceBookQueryReader { + Long findActiveVersionId(); + List findChannels(long versionId, long originWarehouseId); + List findChannelTaxModes(long versionId, long originWarehouseId); + List findFbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode); + List findOverseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode); + List findRegionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode); + ChannelPrefixRow findChannelPrefix(String channelCode); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index e1733566e..003b4085e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -7,9 +7,9 @@ import com.baiyue.firstleg.domain.PriceRegion; import com.baiyue.firstleg.domain.PricingDestinationType; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -21,7 +21,7 @@ import java.util.Locale; /** Single active-price-book lookup used by merchant estimates and inbound automatic billing. */ @Component public class ActivePriceBookEstimator { - @Resource private PriceBookQueryRepository repository; + @Resource private PriceBookQueryUseCase repository; @Resource private FbaWarehouseRateEstimator fbaEstimator; @Resource private RegionWeightTierEstimator regionEstimator; @Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java index 463252d34..897aef553 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.springframework.stereotype.Component; import java.math.BigDecimal; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java index 426593a10..3b9d25f6e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.springframework.stereotype.Component; import java.math.BigDecimal; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index 60fbe9d8c..9a425a5e9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -46,7 +46,6 @@ public class ContainerRepository { public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; } public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; } public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; } - public boolean hasMismatchedCustomerItems(long id, long customerId) { return mapper.hasMismatchedCustomerItems(id, customerId) > 0; } public int updateTransport(long id, String containerNo, String voyageNo, int version, long operatorId, String operatorName) { return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index a82cffec0..403f5a5c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -13,21 +13,36 @@ import java.util.List; @Repository public class LoadingCompletedOutboxRepository { + private static final List CROSS_REGION_EVENT_TYPES = List.of( + EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED); @Resource private InboundOutboxMapper mapper; - public List pending(int limit, LocalDateTime now) { + public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { return mapper.selectList(new LambdaQueryWrapper() - .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) - .eq(InboundOutboxEntity::getPublishStatus, "PENDING") - .and(v -> v.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now)) + .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) + .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) + .or().le(InboundOutboxEntity::getNextRetryAt, now)) + .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") + .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) .orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId) .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); } - public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, base(id, "PENDING") + public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(InboundOutboxEntity::getId, id) + .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) + .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) + .or().le(InboundOutboxEntity::getNextRetryAt, now)) + .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") + .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) - .set(InboundOutboxEntity::getLockedAt, now)) == 1; } + .set(InboundOutboxEntity::getLockedAt, now)) == 1; + } public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() - .eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); } public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker) .set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now) @@ -38,7 +53,7 @@ public class LoadingCompletedOutboxRepository { .set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null) .set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); } private LambdaUpdateWrapper base(long id, String status) { return new LambdaUpdateWrapper() - .eq(InboundOutboxEntity::getId,id).eq(InboundOutboxEntity::getEventType,EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .eq(InboundOutboxEntity::getId,id).in(InboundOutboxEntity::getEventType,CROSS_REGION_EVENT_TYPES) .eq(InboundOutboxEntity::getPublishStatus,status); } private LambdaUpdateWrapper claimedBase(long id,String worker) { return base(id,"PROCESSING").eq(InboundOutboxEntity::getLockedBy,worker); } private static String truncate(String value) { if(value==null)return "unknown"; String v=value.replaceAll("[\\r\\n\\t]+"," "); return v.length()>500?v.substring(0,500):v; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 86fad576e..e363c9a2d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -106,9 +106,13 @@ public class LoadingListRepository { String fromStatus, String toStatus, String beforeSnapshot, String afterSnapshot, long operatorId, String operatorName, String operationReason) { - mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, + int changed = mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName, operationReason)); + if (changed != 1) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.CONFLICT, "装柜历史已变化,请刷新后重试"); + } } private Map> customerMap(List rows) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java deleted file mode 100644 index 1161bf9e3..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.baiyue.firstleg.repository; - -import com.baiyue.firstleg.mapper.PriceBookQueryMapper; -import jakarta.annotation.Resource; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public class PriceBookQueryRepository { - @Resource - private PriceBookQueryMapper mapper; - - public Long activeVersionId() { - return mapper.findActiveVersionId(); - } - - public List channels(long versionId, long originWarehouseId) { - return mapper.findChannels(versionId, originWarehouseId); - } - - public List channelTaxModes(long versionId, long originWarehouseId) { - return mapper.findChannelTaxModes(versionId, originWarehouseId); - } - - public List fbaRates(long versionId, long originWarehouseId, - String channelCode, String destinationCode, - String pricingTaxMode) { - return mapper.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode); - } - - public List overseasRates(long versionId, long originWarehouseId, - String channelCode, long destinationWarehouseId, - String pricingTaxMode) { - return mapper.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId, - pricingTaxMode); - } - - public List regionTiers(long versionId, long originWarehouseId, - String channelCode, String regionCode, - String pricingTaxMode) { - return mapper.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode); - } - - public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) { - return channels(versionId, originWarehouseId).stream() - .anyMatch(channel -> channelCode.equals(channel.getChannelCode())); - } - - public PriceBookQueryRows.ChannelPrefixRow channelPrefix(String channelCode) { - return mapper.findChannelPrefix(channelCode); - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index ab28e444c..73c5bf050 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -31,8 +31,8 @@ public class SmartLoadingRepository { @Resource private InboundOrderMapper inbounds; public List candidates(long warehouseId) { return candidates.candidates(warehouseId); } - public Candidate candidate(long warehouseId, long inboundId, long detailId) { - return candidates.candidate(warehouseId, inboundId, detailId); + public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) { + return candidates.candidate(planId, warehouseId, inboundId, detailId); } public InboundOrderEntity inbound(long accountId, long inboundId) { return inbounds.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java index 2733ac4a8..612018986 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java @@ -44,7 +44,14 @@ public class ContainerOverviewService { List timeline = timeline(id, task == null ? null : task.id(), exceptionHistory); return new Overview(new ContainerBookingEntityView(booking.getId(), booking.getBookingNo(), booking.getCabinetNo(), booking.getContainerNo(), booking.getPlanStatus(), booking.getWarehouseId(), - booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getVersion()), + booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getBillOfLadingNo(), + booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), + booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), + booking.getEstimatedSailingAt(), booking.getActualSailingAt(), booking.getEstimatedPortArrivalAt(), + booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(), + booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(), + booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(), + booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getVersion()), task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents, new ExceptionState(latestException != null && "MARK".equals(latestException.action()), latestException == null ? null : latestException.reason(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index cfc7553c4..8a9d5571e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -22,8 +22,8 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; @@ -44,7 +44,7 @@ public class ContainerService { @Resource private SmartLoadingRepository smartLoadingRepository; @Resource private LoadingPickRepository loadingPickRepository; @Resource private LoadingPickService loadingPickService; - @Resource private PriceBookQueryRepository priceBooks; + @Resource private PriceBookQueryUseCase priceBooks; @Resource private OceanBookingRepository oceanBookings; @Resource private WarehouseSupplierSnapshotService suppliers; @Resource private FirstlegDocumentNumberGenerator documentNumbers; @@ -85,17 +85,14 @@ public class ContainerService { if (booking == null || !"RELEASED".equals(booking.getStatus())) throw conflict("只能关联已发布订舱"); OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(request.oceanBookingId(), request.requirementId()); if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在"); - long allocated = oceanBookings.allocations(request.oceanBookingId()).stream().filter(a -> request.requirementId().equals(a.getRequirementId())).count(); + long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId()); if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限"); if (repository.containerNoExists(request.containerNo().trim(), id)) throw conflict("集装箱号已存在"); if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId()) || !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode()) - || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType()) - || container.getCapacityCbm() == null || requirement.getCapacityCbm() == null - || container.getMaxWeightKg() == null || requirement.getMaxWeightKg() == null - || container.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0 - || container.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) throw conflict("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致"); - if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致"); + || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) { + throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致"); + } if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking, request.expectedVersion(), operatorId, operatorName) != 1) { throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index a5f92a869..025f1776b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -14,7 +14,7 @@ import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import com.baiyue.firstleg.repository.FbaPricingRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java index 64a88398a..679368549 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java @@ -41,7 +41,7 @@ public class FeePaymentSnapshotValidator { } return Snapshot.empty(); } - if (!"PAYABLE".equals(direction) || !MODES.contains(mode)) { + if (!"PAYABLE".equals(direction) || mode == null || !MODES.contains(mode)) { throw bad("应付费用必须选择ON_ACCOUNT或DIRECT_PAID"); } if ("ON_ACCOUNT".equals(mode)) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index 193940b28..ac82be20b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -68,6 +68,7 @@ public class InboundOrderCreationService { inbound.setDeliveryWarehouseId(command.deliveryWarehouseId()); inbound.setDeliveryWarehouseName(command.deliveryWarehouseName()); inbound.setBusinessType(command.businessType()); + inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType())); inbound.setChannelCode(command.channelCode()); inbound.setChannelName(command.channelName()); inbound.setCarrierId(command.carrierId()); @@ -160,6 +161,7 @@ public class InboundOrderCreationService { .set(InboundOrderEntity::getDeliveryWarehouseName, command.deliveryWarehouseName()) .set(InboundOrderEntity::getExpectedArrivalAt, command.expectedArrivalAt()) .set(InboundOrderEntity::getBusinessType, command.businessType()) + .set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType())) .set(InboundOrderEntity::getChannelCode, command.channelCode()) .set(InboundOrderEntity::getChannelName, command.channelName()) .set(InboundOrderEntity::getDestinationType, command.destinationType()) @@ -181,6 +183,7 @@ public class InboundOrderCreationService { .set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE") .set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED") .set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount()) + .set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType())) .set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now()) .setSql("version = version + 1"); if (orders.update(null, update) != 1) { @@ -217,6 +220,7 @@ public class InboundOrderCreationService { inbound.setDeliveryWarehouseId(command.deliveryWarehouseId()); inbound.setDeliveryWarehouseName(command.deliveryWarehouseName()); inbound.setBusinessType(command.businessType()); + inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType())); inbound.setChannelCode(command.channelCode()); inbound.setChannelName(command.channelName()); inbound.setCarrierId(command.carrierId()); @@ -241,6 +245,12 @@ public class InboundOrderCreationService { return inbound; } + private static String fulfillmentType(String value) { + if ("STANDARD".equals(value) || "ONE_CLICK_DROPSHIP".equals(value)) return value; + throw new BizException(ResultCode.BAD_REQUEST, + "履约类型必须为 STANDARD 或 ONE_CLICK_DROPSHIP"); + } + private void addHistory(long inboundId, long tenantId, String from, String to, String action, LocalDateTime now) { InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index bc3052d4a..f01597951 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -1,5 +1,6 @@ package com.baiyue.firstleg.service; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.firstleg.client.EventBridgeClient; @@ -15,33 +16,71 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.UUID; -/** 至少一次投递装柜完成跨区事件;事件桥按 eventId 幂等接收。 */ +/** 至少一次投递国内头程跨区事件;事件桥按 eventId + payloadHash 幂等接收。 */ @Component @Slf4j public class LoadingCompletedOutboxRelay { @Resource private LoadingCompletedOutboxRepository repository; @Resource private EventBridgeClient client; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.lastleg.outbox-relay.lease-seconds:60}") private long leaseSeconds; + @Value("${baiyue.lastleg.outbox-relay.retry-seconds:30}") private long retrySeconds; private final String workerId = UUID.randomUUID().toString(); @Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}") public void relay() { LocalDateTime now=LocalDateTime.now(); - for (InboundOutboxEntity candidate:repository.pending(50,now)) { - if(!repository.claim(candidate.getId(),workerId,now)) continue; + LocalDateTime expiredLeaseBefore=now.minusSeconds(Math.max(leaseSeconds,10)); + for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) { + if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue; InboundOutboxEntity 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); - if(response==null || !response.successful() || response.data()==null - || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件"); + requireAcknowledgement(event, response); repository.published(row.getId(),workerId,LocalDateTime.now()); } catch(Exception exception) { - repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(30),exception.getMessage()); - log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),exception.getMessage()); + String error=safeError(exception); + repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error); + log.warn("头程跨区事件投递失败: eventType={}, eventKey={}, reason={}", + row.getEventType(), row.getEventKey(), error); } } } + + private void requireAcknowledgement(EventEnvelope event, + ApiResponse response) { + if(response==null) throw new IllegalStateException("事件桥未返回响应"); + if(!response.successful()) { + throw new IllegalStateException("事件桥拒绝头程跨区事件: code=" + response.code() + + ", message=" + response.message() + ", requestId=" + response.requestId() + + ", traceId=" + response.traceId()); + } + if(response.data()==null || !event.eventId().equals(response.data().eventId())) { + throw new IllegalStateException("事件桥 ACK 不匹配: expectedEventId=" + event.eventId() + + ", actualEventId=" + (response.data()==null?null:response.data().eventId())); + } + } + + private String safeError(Exception exception) { + Throwable diagnostic=diagnosticCause(exception); + String message=diagnostic.getMessage(); + String value=diagnostic.getClass().getSimpleName() + + ((message==null || message.isBlank())?"":": " + message); + if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***"); + value=value.replaceAll("[\\r\\n\\t]+"," "); + return value.length()>500?value.substring(0,500):value; + } + + private Throwable diagnosticCause(Throwable failure) { + Throwable current=failure; + while(current.getCause()!=null && current.getCause()!=current + && ("NoFallbackAvailableException".equals(current.getClass().getSimpleName()) + || "No fallback available.".equals(current.getMessage()))) { + current=current.getCause(); + } + return current; + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 5fc9bc31a..d2c706779 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -14,6 +14,9 @@ import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -22,36 +25,46 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; /** 在装柜完成事务内按客户写跨区业务 Outbox。 */ @Service public class LoadingCompletedOutboxService { private static final long HANDOVER_VERSION = 2L; @Resource private LoadingCompletedEventMapper snapshotMapper; + @Resource private LoadingCartonManifestMapper cartonManifestMapper; @Resource private InboundOutboxMapper outboxMapper; @Resource private ObjectMapper objectMapper; public void publish(long planId) { List rows = snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); + Map> cartonMarks = cartonLabelPools(rows); + List palletRows = snapshotMapper.palletRows(planId); rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, java.util.TreeMap::new, java.util.stream.Collectors.toList())) - .forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows))); + .forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows, + palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList(), cartonMarks))); } - private EventEnvelope envelope(long accountId, List rows) { + private EventEnvelope envelope(long accountId, List rows, List palletRows, + Map> cartonMarks) { Row first = rows.get(0); Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); List orders = byInbound.values().stream() - .map(this::order).toList(); + .map(value -> order(value, palletRows.stream() + .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList(); var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(), string(first.getWarehouseId()), first.getWarehouseName(), orders); @@ -65,7 +78,8 @@ public class LoadingCompletedOutboxService { traceId, eventKey, null, payloadNode, hash).requireValid(); } - private FirstLegLoadingPlanCompletedV2.Order order(List values) { + private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows, + Map> cartonMarks) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); List items = sorted.stream().map(row -> @@ -74,16 +88,160 @@ public class LoadingCompletedOutboxService { row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(), row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), - row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark())) + row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null, + cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))) .toList(); + String fulfillmentType = fulfillmentType(sorted); + String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; + List pallets = dropship(fulfillmentType) + ? palletSnapshots(sorted, palletRows) : List.of(); + if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); + } return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()), string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(), string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), - sumNullableInt(sorted, Row::getProductQuantity), sumNullableInt(sorted, Row::getPalletCount), - sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), sum(sorted, Row::getVolumeCbm), items); + sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType) + ? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), + sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets, + destinationType(sorted)); } + /** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */ + private Map> cartonLabelPools(List rows) { + Map> result = new java.util.LinkedHashMap<>(); + Map> byInbound = rows.stream().filter(row -> !dropship(row.getFulfillmentType())) + .filter(row -> "OVERSEAS".equals(destinationType(List.of(row)))) + .collect(java.util.stream.Collectors.groupingBy( + row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()), + LinkedHashMap::new, java.util.stream.Collectors.toList())); + for (Map.Entry> entry : byInbound.entrySet()) { + List inboundRows = entry.getValue().stream() + .sorted(Comparator.comparing(Row::getLoadingPlanItemId)).toList(); + if (inboundRows.stream().anyMatch(row -> row.getSkuCode() == null || row.getSkuCode().isBlank())) { + throw new BizException(ResultCode.CONFLICT, "装柜明细缺少 SKU,不能建立箱唛与 SKU 的对应关系"); + } + List labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool( + entry.getKey().accountId(), entry.getKey().inboundId()), List.of()); + if (labels.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件"); + } + if (labels.stream().anyMatch(label -> label == null || label.getBoxCode() == null + || label.getBoxCode().isBlank()) + || labels.stream().map(LabelRow::getBoxCode).distinct().count() != labels.size()) { + throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛池包含空值或重复箱号"); + } + Set used = new HashSet<>(); + for (Row row : inboundRows) { + int loadedPackages = Objects.requireNonNullElse(row.getPackageCount(), 0); + String seed = row.getLoadingPlanId() + ":" + row.getLoadingPlanItemId(); + Comparator stableOrder = Comparator.comparing(label -> EventPayloadHash.sha256( + (seed + ":" + label.getBoxCode()).getBytes(StandardCharsets.UTF_8))); + List selected = new ArrayList<>(); + labels.stream().filter(label -> !used.contains(label.getBoxCode()) + && Objects.equals(label.getInboundDetailId(), row.getSourceInboundDetailId())) + .sorted(stableOrder).limit(loadedPackages).map(LabelRow::getBoxCode).forEach(selected::add); + if (selected.size() < loadedPackages) { + labels.stream().filter(label -> !used.contains(label.getBoxCode()) + && !selected.contains(label.getBoxCode())) + .sorted(stableOrder).limit(loadedPackages - selected.size()) + .map(LabelRow::getBoxCode).forEach(selected::add); + } + if (selected.size() < loadedPackages) { + throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件"); + } + used.addAll(selected); + result.put(row.getLoadingPlanItemId(), List.copyOf(selected)); + } + } + return result; + } + + private String destinationType(List rows) { + Set values = rows.stream().map(Row::getDestinationType) + .map(value -> value == null ? "" : value.trim().toUpperCase(java.util.Locale.ROOT)) + .collect(java.util.stream.Collectors.toSet()); + if (values.size() != 1 || (!values.contains("FBA") && !values.contains("OVERSEAS"))) { + throw new BizException(ResultCode.CONFLICT, + "装柜入仓单目的类型必须唯一且为 FBA 或 OVERSEAS,不能生成海外交接事件"); + } + return values.iterator().next(); + } + + private String fulfillmentType(List rows) { + Set values = rows.stream().map(Row::getFulfillmentType) + .map(value -> value == null || value.isBlank() ? "STANDARD" : value) + .collect(java.util.stream.Collectors.toSet()); + if (values.size() != 1 + || (!values.contains("STANDARD") && !values.contains("ONE_CLICK_DROPSHIP"))) { + throw new BizException(ResultCode.CONFLICT, + "装柜入仓单履约类型必须唯一且为 STANDARD 或 ONE_CLICK_DROPSHIP"); + } + return values.iterator().next(); + } + + private record LabelPoolKey(long accountId, long inboundId) { } + + private List palletSnapshots(List values, List source) { + if (source.isEmpty()) return List.of(); + Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( + p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(), + LinkedHashMap::new, java.util.stream.Collectors.toList())); + int loadedPackages = byPallet.values().stream().map(list -> list.get(0)).map(PalletRow::getAllocatedPackageCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + if (loadedPackages != sumInt(values, Row::getPackageCount)) { + throw new BizException(ResultCode.CONFLICT, "一键代发实装箱数与托盘已拣箱数不一致"); + } + Map details = new LinkedHashMap<>(); + values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).forEach(row -> + details.putIfAbsent(row.getSourceInboundDetailId(), new DetailSnapshot(row.getSourceInboundDetailId(), + row.getSkuCode(), row.getProductNameZh(), row.getPackageCount(), row.getProductQuantity(), new ArrayList<>()))); + source.stream().filter(item -> item.getBoxCode() != null).forEach(item -> { + DetailSnapshot detail = details.get(item.getSourceInboundDetailId()); + if (detail != null && !detail.marks().contains(item.getBoxCode())) detail.marks().add(item.getBoxCode()); + }); + Map remainingPackages = details.values().stream().collect(java.util.stream.Collectors.toMap( + DetailSnapshot::detailId, DetailSnapshot::packageCount, (a,b)->a, LinkedHashMap::new)); + Map remainingMarks = details.values().stream().collect(java.util.stream.Collectors.toMap( + DetailSnapshot::detailId, ignored -> 0, (a,b)->a, LinkedHashMap::new)); + List result = new ArrayList<>(); + for (List palletRows : byPallet.values()) { + PalletRow first = palletRows.get(0); + int capacity = first.getAllocatedPackageCount() == null ? 0 : first.getAllocatedPackageCount(); + List sku = new ArrayList<>(); + for (DetailSnapshot detail : details.values()) { + int remaining = remainingPackages.getOrDefault(detail.detailId(), 0); + if (capacity <= 0 || remaining <= 0) continue; + int count = Math.min(capacity, remaining); + int totalQuantity = detail.productQuantity() == null ? count + : (int) Math.round((double) detail.productQuantity() * count / Math.max(1, detail.packageCount())); + int markStart = remainingMarks.getOrDefault(detail.detailId(), 0); + int markEnd = Math.min(detail.marks().size(), markStart + count); + List marks = new ArrayList<>(detail.marks().subList(markStart, markEnd)); + if (marks.size() != count) throw new BizException(ResultCode.CONFLICT, "一键代发箱唛数量与托盘箱数不一致"); + sku.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.skuCode(), + detail.productName(), count, totalQuantity, marks)); + remainingPackages.put(detail.detailId(), remaining - count); + remainingMarks.put(detail.detailId(), markEnd); + capacity -= count; + } + if (!sku.isEmpty()) result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot( + first.getPalletCode() == null ? "PALLET-" + first.getAllocationNo() : first.getPalletCode(), sku)); + } + if (remainingPackages.values().stream().mapToInt(Integer::intValue).sum() != 0) { + throw new BizException(ResultCode.CONFLICT, "一键代发托盘未覆盖全部实装货物明细"); + } + return result; + } + + private static boolean dropship(String value) { + return "ONE_CLICK_DROPSHIP".equals(value); + } + + private record DetailSnapshot(Long detailId, String skuCode, String productName, + Integer packageCount, Integer productQuantity, List marks) {} + private void insert(EventEnvelope envelope) { InboundOutboxEntity value = new InboundOutboxEntity(); value.setId(IdWorker.getId()); value.setTenantId(Long.valueOf(envelope.tenantId())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 2b287f49d..c77be6c49 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -31,6 +31,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.context.annotation.Lazy; import org.springframework.transaction.annotation.Transactional; @@ -55,7 +56,7 @@ public class LoadingListService { public static final String DELIVERY_ORDER = "DELIVERY_ORDER"; public static final String PICKUP_ORDER = "PICKUP_ORDER"; private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT"; - private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DOCUMENT"; + private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION"; private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER"; private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER"; @Resource @@ -128,6 +129,41 @@ public class LoadingListService { } catch(FeignException e){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} } + public DocumentDownload downloadContainerClearance(long bookingId, long fileId) { + requireBookingPlan(bookingId); + ContainerDocumentEntity document = repository.document(bookingId, DESTINATION_CLEARANCE); + if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) { + throw notFound("目的港清关附件不存在或不属于当前货柜"); + } + String businessId = Long.toString(bookingId); + try { + var metadata = documentFiles.metadata(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId); + if (metadata == null || !metadata.successful() || metadata.data() == null + || !Long.valueOf(fileId).equals(metadata.data().fileId()) + || !Long.valueOf(0).equals(metadata.data().tenantId()) + || !CLEARANCE_FILE_TYPE.equals(metadata.data().businessType()) + || !businessId.equals(metadata.data().businessId())) { + throw notFound("目的港清关附件不存在或归属不匹配"); + } + var response = documentFiles.download(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw notFound("目的港清关附件不存在"); + } + MediaType contentType; + try { + contentType = MediaType.parseMediaType(metadata.data().contentType()); + } catch (RuntimeException exception) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + String fileName = StringUtils.hasText(document.getFileName()) + ? document.getFileName() : metadata.data().fileName(); + return new DocumentDownload(fileName, contentType, response.getBody()); + } catch (FeignException.NotFound exception) { + throw notFound("目的港清关附件不存在或归属不匹配"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {} private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); } private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; } @@ -169,6 +205,44 @@ public class LoadingListService { return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION)); } + public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) { + AggregateRow plan = requirePlan(planId); + requireWarehouse(plan, warehouseId); + requireCompletedPlan(plan); + ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION); + if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) { + throw notFound("出口报关附件不存在或不属于当前装柜计划"); + } + String businessId = Long.toString(planId); + try { + var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId); + if (metadata == null || !metadata.successful() || metadata.data() == null + || !Long.valueOf(fileId).equals(metadata.data().fileId()) + || !Long.valueOf(0).equals(metadata.data().tenantId()) + || !DECLARATION_FILE_TYPE.equals(metadata.data().businessType()) + || !businessId.equals(metadata.data().businessId())) { + throw notFound("出口报关附件不存在或归属不匹配"); + } + var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw notFound("出口报关附件不存在"); + } + MediaType contentType; + try { + contentType = MediaType.parseMediaType(metadata.data().contentType()); + } catch (RuntimeException exception) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + String fileName = StringUtils.hasText(document.getFileName()) + ? document.getFileName() : metadata.data().fileName(); + return new DocumentDownload(fileName, contentType, response.getBody()); + } catch (FeignException.NotFound exception) { + throw notFound("出口报关附件不存在或归属不匹配"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } + @Transactional @BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "上传出口报关文档") @@ -264,12 +338,18 @@ public class LoadingListService { if (changed != 1) throw conflict("资料版本已变化,请刷新后重试"); } catch (DuplicateKeyException exception) { throw conflict("资料已被其他操作上传,请刷新后重试"); + } catch (DataIntegrityViolationException exception) { + throw conflict("资料状态或数据库约束已变化,请刷新后重试"); } ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType); - repository.appendHistory(plan.getPlanId(), operationId, actionType, - plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), - json(values("requestHash", requestHash, "document", documentSnapshot(saved))), - operatorId, trim(operatorName), trim(operationReason)); + try { + repository.appendHistory(plan.getPlanId(), operationId, actionType, + plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), + json(values("requestHash", requestHash, "document", documentSnapshot(saved))), + operatorId, trim(operatorName), trim(operationReason)); + } catch (DataIntegrityViolationException exception) { + throw conflict("资料历史约束已变化,请刷新后重试"); + } return document(saved); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 1e5f3cd45..1dcd6ad1a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -450,17 +450,21 @@ public class LoadingPickService { throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成"); } if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成"); - List owners = repository.inboundOwners(taskId).stream() + List discoveredOwners = repository.inboundOwners(taskId).stream() .filter(owner -> owner.tenantId() != null && owner.inboundId() != null) + .toList(); + if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单"); + LoadingPickItemMapper.InboundOwner evidenceOwner = discoveredOwners.get(0); + List owners = discoveredOwners.stream() .sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId) .thenComparing(LoadingPickItemMapper.InboundOwner::inboundId)) .toList(); - if (owners.isEmpty()) throw conflict("PDA任务未关联有效入库单"); for (LoadingPickItemMapper.InboundOwner owner : owners) { LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId()); if (snapshot == null) throw conflict("PDA任务关联入库单不存在"); completionStage(taskId, "完成前业务门禁", () -> - loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId())); + loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(), + evidenceOwner.inboundId())); } completionStage(taskId, "拆单与库位结算", () -> settlementService.settle(taskId, operatorId, operatorName)); @@ -476,8 +480,9 @@ public class LoadingPickService { repository.syncInboundProjections(taskId, operatorId, operatorName); for (LoadingPickItemMapper.InboundOwner owner : owners) { completionStage(taskId, "入库单状态完成", () -> - loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), operatorId, - operatorName, "PDA-" + operationId + '-' + owner.inboundId(), remark)); + loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), + evidenceOwner.inboundId(), operatorId, operatorName, + "PDA-" + operationId + '-' + owner.inboundId(), remark)); } completionStage(taskId, "完成事件发布", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index 87d608bca..d913613d4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -9,9 +9,9 @@ import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse; import com.baiyue.firstleg.dto.PriceBookChannelValidation.Response; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -26,7 +26,7 @@ import java.util.Map; public class MerchantPriceBookService { public static final String DISCLAIMER = "仅供参考,不含附加费,最终以实际结算为准"; - @Resource private PriceBookQueryRepository repository; + @Resource private PriceBookQueryUseCase repository; @Resource private ActivePriceBookEstimator estimator; public List channels(long merchantTenantId, long originWarehouseId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java new file mode 100644 index 000000000..1873105dd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -0,0 +1,121 @@ +package com.baiyue.firstleg.service; + +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.OverseasContainerUnloadedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.ContainerEventInboxEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +@Service +@RequiredArgsConstructor +public class OverseasContainerEventConsumer { + private final ContainerBookingMapper containers; + private final ContainerEventInboxMapper inbox; + private final ObjectMapper objectMapper; + + @Transactional + public ContainerBookingEntity consume(EventEnvelope event) { + validateEnvelope(event); + OverseasContainerUnloadedV1 payload = payload(event); + if (payload.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank() + || !payload.sourceContainerBookingId().equals(event.aggregateId())) { + throw new IllegalArgumentException("事件 aggregateId 与 sourceContainerBookingId 不一致"); + } + if (payload.containerNo() == null || payload.containerNo().isBlank()) { + throw new IllegalArgumentException("货柜号不能为空"); + } + if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) { + throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空"); + } + if (payload.actualEmptyReportedAt() != null + && payload.actualEmptyReportedAt().isBefore(payload.unloadingPhotoUploadedAt())) { + throw new IllegalArgumentException("实际报空时间不能早于卸柜照片上传时间"); + } + + ContainerEventInboxEntity existing = inbox.selectById(event.eventId()); + if (existing != null) { + if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { + throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); + } + return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); + } + ContainerEventInboxEntity row = inbox(event); + try { + inbox.insert(row); + } catch (DuplicateKeyException duplicate) { + ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); + if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; + return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); + } + + ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); + LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt()); + containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt, + uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); + row.setStatus("PROCESSED"); + row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); + inbox.updateById(row); + return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); + } + + private void validateEnvelope(EventEnvelope event) { + event.requireValid(); + if (!EventTypes.OVERSEAS_CONTAINER_UNLOADED.equals(event.eventType()) || event.eventVersion() != 1) { + throw new IllegalArgumentException("不支持的海外货柜事件"); + } + if (event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN + || !"CONTAINER_BOOKING".equals(event.aggregateType())) { + throw new IllegalArgumentException("海外货柜事件区域或聚合类型无效"); + } + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { + throw new IllegalArgumentException("payloadHash 与事件负载不一致"); + } + } + + private OverseasContainerUnloadedV1 payload(EventEnvelope event) { + try { return objectMapper.treeToValue(event.payload(), OverseasContainerUnloadedV1.class); } + catch (Exception exception) { throw new IllegalArgumentException("海外货柜事件负载无效", exception); } + } + + private ContainerEventInboxEntity inbox(EventEnvelope event) { + ContainerEventInboxEntity row = new ContainerEventInboxEntity(); + row.setEventId(event.eventId()); row.setEventType(event.eventType()); row.setEventVersion(event.eventVersion()); + row.setAggregateId(event.aggregateId()); row.setAggregateVersion(event.aggregateVersion()); + row.setPayloadHash(event.payloadHash().toLowerCase(java.util.Locale.ROOT)); + row.setPayloadJson(event.payload().toString()); row.setStatus("RECEIVED"); + row.setReceivedAt(LocalDateTime.now(ZoneOffset.UTC)); + return row; + } + + private ContainerBookingEntity requiredContainer(String sourceContainerBookingId, String containerNo) { + long bookingId; + try { + bookingId = Long.parseLong(sourceContainerBookingId); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException("国内货柜订舱 ID 无效", exception); + } + ContainerBookingEntity value = containers.selectActiveBookingForUpdate(bookingId); + if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用"); + if (value.getContainerNo() == null || !value.getContainerNo().equals(containerNo)) { + throw new IllegalArgumentException("事件货柜号与国内订舱记录不一致"); + } + return value; + } + + private LocalDateTime utc(java.time.Instant value) { + return value == null ? null : LocalDateTime.ofInstant(value, ZoneOffset.UTC); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index 7207d64e0..f7b6d0a8e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -185,6 +185,9 @@ public class PdaPickSettlementService { requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId, orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(), remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单"); + splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId, + orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount); + splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount); } requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId, idempotencyKey, rootVersion, parentAvailable, null, remainderChildId == 0L ? null : remainderChildId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index fefa398ca..c03fafb0b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -132,7 +132,8 @@ public class SmartLoadingService { if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId()); + Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), request.inboundId(), + request.inboundDetailId()); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); @@ -300,8 +301,15 @@ public class SmartLoadingService { LoadingPlanItemEntity item = repository.item(planId, itemId); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); + LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId); + int pickedPackageCount = pickSnapshot == null ? 0 + : pickSnapshot.pickedByPlanItem().getOrDefault(itemId, 0); + if (request.packageCount() < pickedPackageCount) { + throw conflict("计划箱数不能低于已拣货箱数"); + } ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId()); + Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), item.getInboundId(), + item.getInboundDetailId()); if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 778d1ce6d..4162cc757 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -83,13 +83,20 @@ public class SplitPreloadService { } private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId, - String operatorName, boolean allowConfirmed) { + String operatorName, boolean running) { InboundSnapshot inbound = requireInbound(tenantId, inboundId); - requireWaitingSplit(inbound, "仅待分柜订单可以建立智能装柜预装计划"); PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); + boolean waitingSplit = isWaitingSplit(inbound.processStatus()); + boolean loading = "LOADING".equals(inbound.processStatus()); + if (!waitingSplit && !(running && loading)) { + throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划"); + } + if (loading && plan == null) { + throw new BizException(ResultCode.CONFLICT, "装柜中的订单缺少既有预装计划,不能新增分配"); + } if (plan != null) { if (!PreloadStatus.PENDING.equals(plan.getStatus()) - && !(allowConfirmed && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) { + && !(running && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) { throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); } return; diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index 60af1f819..1a5342f3a 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -25,9 +25,26 @@ spring: max-file-size: 5MB max-request-size: 6MB baiyue: + carrier-schedule: + providers: + matson-automated: ${MATSON_TRACKING_ENABLED:false} + sml-automated: ${SML_TRACKING_ENABLED:false} + zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} + cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} + emc-automated: ${EMC_TRACKING_ENABLED:false} + hede-automated: ${HEDE_TRACKING_ENABLED:false} + scheduler: + zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} + scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} + scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} + batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} + max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} + lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} 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} + lastleg: + event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:} clients: order: url: ${ORDER_SERVICE_URL:} diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 751744485..902aede82 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', channel_code VARCHAR(64) NULL COMMENT '渠道编码', channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', @@ -77,6 +78,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +SET @inbound_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type' +); +PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql; +EXECUTE inbound_fulfillment_type_stmt; +DEALLOCATE PREPARE inbound_fulfillment_type_stmt; + +UPDATE fl_inbound_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE fl_inbound_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + -- 既有开发库若存在跨客户重复码则严格失败,禁止自动覆盖或改写入仓码。 SET @inbound_global_code_index_sql = IF( NOT EXISTS( @@ -769,7 +790,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_outbox ( aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型', aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', event_type VARCHAR(64) NOT NULL COMMENT '事件类型', - payload_json JSON NOT NULL COMMENT '事件负载快照', + payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法', publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '事件发布状态:PENDING待发布、PUBLISHED已发布、FAILED发布失败', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index 93a182dc1..0f47efd54 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -8,15 +8,23 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空', container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0', - carrier_code VARCHAR(16) NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德', + carrier_code VARCHAR(16) NOT NULL COMMENT '船公司标准编码;OTHER表示人工跟踪', tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', - access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', - status VARCHAR(24) NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果', - window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间', - attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次', - next_allowed_at DATETIME(3) NOT NULL COMMENT '频控规则允许的下次查询时间', + access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式;MANUAL表示仅人工补录节点', + status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED', + trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', + latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', + latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间', + stopped_at DATETIME(3) NULL COMMENT '任务停止时间', + stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', + lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前正常查询槽位内的连续技术失败次数', + window_started_at DATETIME(3) NOT NULL COMMENT '当前正常查询槽位时间', + attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前正常查询槽位已调用次数;最多3次', + next_allowed_at DATETIME(3) NULL COMMENT '下一正常槽位或槽内技术退避时间;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -37,7 +45,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( PRIMARY KEY (id), UNIQUE KEY uk_carrier_schedule_query_no (query_no), UNIQUE KEY uk_carrier_schedule_query_scope - (customer_id,ocean_booking_id,container_scope_id,carrier_code,tracking_type,tracking_no), + (customer_id,ocean_booking_id,container_scope_id), KEY idx_carrier_schedule_query_due (status,next_allowed_at,id), KEY idx_carrier_schedule_query_customer (customer_id,updated_at,id), KEY idx_carrier_schedule_query_expiry (expires_at,id), @@ -46,24 +54,28 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id), CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0), CONSTRAINT chk_carrier_schedule_query_carrier CHECK - (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE')), + (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_carrier_schedule_query_tracking CHECK (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API','MANUAL')), CONSTRAINT chk_carrier_schedule_query_status CHECK - (status IN ('READY','MANUAL_REQUIRED','SUCCESS')), + (status IN ('READY','MANUAL_REQUIRED','STOPPED')), + CONSTRAINT chk_carrier_schedule_query_node CHECK + (latest_standard_node IS NULL OR latest_standard_node IN + ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT', + 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) -) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; +) ENGINE=InnoDB COMMENT='六家船公司固定槽位与技术退避查询任务'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询尝试ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前三天窗口内的尝试序号;取值1至3', + window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前正常查询槽位内的调用序号;取值1至3', carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', - attempt_status VARCHAR(24) NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败', + attempt_status VARCHAR(24) NOT NULL COMMENT '供应商调用结果', coverage_gap TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在自动化覆盖缺口:0否、1是', coverage_gap_code VARCHAR(64) NULL COMMENT '自动化覆盖缺口编码', manual_reason VARCHAR(1000) NULL COMMENT '转人工处理原因', @@ -77,13 +89,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK - (attempt_status IN ('MANUAL_REQUIRED','SUCCESS','FAILED')) + (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - action VARCHAR(24) NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果', + action VARCHAR(24) NOT NULL COMMENT '查询任务业务历史动作', from_version INT UNSIGNED NULL COMMENT '变更前任务版本;创建时为空', to_version INT UNSIGNED NOT NULL COMMENT '变更后任务版本', reason VARCHAR(1000) NULL COMMENT '操作原因或说明', @@ -95,9 +107,62 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( PRIMARY KEY (id), KEY idx_carrier_schedule_history_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), - CONSTRAINT chk_carrier_schedule_history_action CHECK (action IN ('CREATE','ATTEMPT','MANUAL_RESULT')) + CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')) ) ENGINE=InnoDB COMMENT='船司查询任务不可变历史'; +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', + provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码', + standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点', + node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + estimated_at DATETIME(3) NULL COMMENT '供应商预计发生时间', + actual_at DATETIME(3) NULL COMMENT '供应商确认实际发生时间', + location_code VARCHAR(32) NULL COMMENT '节点地点编码', + location_name VARCHAR(128) NULL COMMENT '节点地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key), + KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), + CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_node_standard CHECK + (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT', + 'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), + CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60,70,80,90,100)) +) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实'; + +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '船节点异常事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', + provider_status VARCHAR(128) NOT NULL COMMENT '供应商原始异常状态', + exception_code VARCHAR(32) NOT NULL COMMENT '平台异常编码', + active TINYINT(1) NOT NULL COMMENT '1异常发生;0异常解除', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + location_code VARCHAR(32) NULL COMMENT '地点编码', + location_name VARCHAR(128) NULL COMMENT '地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_exception_event (query_id,event_key), + KEY idx_carrier_schedule_exception_timeline (query_id,observed_at,id), + CONSTRAINT fk_carrier_schedule_exception_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_exception_code CHECK + (exception_code IN ('CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD')), + CONSTRAINT chk_carrier_schedule_exception_active CHECK (active IN (0,1)) +) ENGINE=InnoDB COMMENT='船节点异常发生与解除不可变事实'; + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID', query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', @@ -116,6 +181,26 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( CONSTRAINT chk_carrier_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) ) ENGINE=InnoDB COMMENT='船司查询写操作幂等与请求摘要'; +-- CREATE TABLE IF NOT EXISTS 不会更新已有表的 CHECK。历史开发库仍可能只允许 +-- CREATE/ATTEMPT/MANUAL_RESULT,必须在人工节点和自动节点写入前收敛为当前动作集。 +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; +DELIMITER $$ +CREATE PROCEDURE migrate_carrier_schedule_history_action_check() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query_history' + AND constraint_name='chk_carrier_schedule_history_action' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query_history DROP CHECK chk_carrier_schedule_history_action; + END IF; + ALTER TABLE fl_carrier_schedule_query_history + ADD CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')); +END$$ +DELIMITER ; +CALL migrate_carrier_schedule_history_action_check(); +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check; + DROP TRIGGER IF EXISTS trg_carrier_schedule_query_before_insert; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW @@ -138,6 +223,10 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_delete; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_delete; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$ @@ -147,25 +236,41 @@ CREATE TRIGGER trg_carrier_schedule_history_before_update BEFORE UPDATE ON fl_ca FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可修改'; END$$ CREATE TRIGGER trg_carrier_schedule_history_before_delete BEFORE DELETE ON fl_carrier_schedule_query_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_update BEFORE UPDATE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_delete BEFORE DELETE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可删除'; END$$ DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); + DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; DELIMITER $$ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102184 AND 5102188 + WHERE (id IN (5102184,5102186,5102188) AND permission_code <> CASE id WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list' - WHEN 5102185 THEN 'platform:firstleg:port-schedule-query:view' WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create' - WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt' WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual' END) + OR id IN (5102185,5102187) OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%' - AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT BETWEEN 5102184 AND 5102188)) + AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102188))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用'; @@ -180,9 +285,7 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0), - (5102185,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:view','查看船司任务',NULL,'GET',50,'ACTIVE',1,0), (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0), - (5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0), (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), @@ -203,12 +306,13 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_firstleg' AND table_name IN ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', - 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation')) <> 4 THEN + 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation', + 'fl_carrier_schedule_node_fact','fl_carrier_schedule_exception_fact')) <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188 - AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 5 THEN + WHERE tenant_id=0 AND id IN (5102184,5102186,5102188) + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; END IF; IF (SELECT COUNT(*) @@ -217,7 +321,7 @@ BEGIN JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND permission.tenant_id=0 - AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 10 THEN + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; END IF; END$$ diff --git a/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql b/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql index 4b5514187..3190364e2 100644 --- a/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql +++ b/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql @@ -1,2 +1,4 @@ -- 装柜完成跨区事件使用标准 sha256: 摘要,共 71 字符。 ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; +-- MySQL JSON 会重写 0.3150 等数值词法,导致入库前计算的 payloadHash 失效。 +ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index a49ede7f6..3e9a30339 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填', booking_agent_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照', booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照', vessel_name VARCHAR(128) NULL COMMENT '当前确认船名快照', @@ -42,6 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0), CONSTRAINT chk_ocean_booking_status CHECK (status IN ('DRAFT','RELEASED')), + CONSTRAINT chk_ocean_booking_carrier_code CHECK + (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_ocean_booking_schedule CHECK (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; @@ -232,24 +235,23 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_ocean_booking_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102140 AND 5102149 - AND permission_code <> CASE id - WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list' - WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list' - WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create' - WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release' - WHEN 5102147 THEN 'platform:firstleg:ocean-booking:edit' - WHEN 5102148 THEN 'platform:firstleg:ocean-booking:delete' - WHEN 5102149 THEN 'platform:firstleg:ocean-booking:allocate' - END) + WHERE (id = 5102140 AND permission_code <> 'platform:firstleg:ocean-booking:list') + OR (id = 5102142 AND permission_code <> 'platform:firstleg:ocean-booking:create') + OR (id = 5102146 AND permission_code <> 'platform:firstleg:ocean-booking:release') + OR (id = 5102147 AND permission_code <> 'platform:firstleg:ocean-booking:edit') + OR (id = 5102148 AND permission_code <> 'platform:firstleg:ocean-booking:delete') + OR (id = 5102149 AND permission_code <> 'platform:firstleg:ocean-booking:allocate') OR (permission_code LIKE 'platform:firstleg:ocean-booking:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' + OR id NOT IN (5102140,5102142,5102146,5102147,5102148,5102149))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用'; @@ -278,10 +280,4 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); -DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id -WHERE p.id=5102141; -UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141; -DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); -DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); - USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql b/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql index fb055126f..0c90d88d5 100644 --- a/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql +++ b/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql @@ -122,21 +122,28 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:port-schedule:view'; +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule:view'; + DROP PROCEDURE IF EXISTS assert_port_schedule_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_port_schedule_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102180 AND 5102183 + WHERE (id IN (5102180,5102182,5102183) AND permission_code <> CASE id WHEN 5102180 THEN 'platform:firstleg:port-schedule:list' - WHEN 5102181 THEN 'platform:firstleg:port-schedule:view' WHEN 5102182 THEN 'platform:firstleg:port-schedule:create' WHEN 5102183 THEN 'platform:firstleg:port-schedule:edit' END) + OR id=5102181 OR (permission_code LIKE 'platform:firstleg:port-schedule:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102180 AND 5102183)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT IN (5102180,5102182,5102183))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期港务船期权限迁移失败:5102180-5102183 ID 或权限码被其他语义占用'; @@ -151,7 +158,6 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102180,0,'PLATFORM','MENU',5102000,'platform:firstleg:port-schedule:list','港务船期','/first-leg/port-schedules','GET',120,'ACTIVE',1,0), - (5102181,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:view','查看港务船期',NULL,'GET',10,'ACTIVE',1,0), (5102182,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:create','新增港务船期',NULL,'POST',20,'ACTIVE',1,0), (5102183,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:edit','更新港务船期',NULL,'PUT',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), diff --git a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql index 552d13600..1ee186d2b 100644 --- a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql +++ b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql @@ -70,7 +70,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增'; END IF; END$$ @@ -84,7 +84,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改'; END IF; END$$ diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 00e4d9dfd..5033d5924 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -72,6 +72,11 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间', + unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)', + unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)', + estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)', + actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)', + empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -93,6 +98,21 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) ) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; +CREATE TABLE IF NOT EXISTS fl_container_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本', + aggregate_id VARCHAR(64) NOT NULL COMMENT '货柜聚合ID', + aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '货柜聚合版本', + payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要', + payload_json JSON NOT NULL COMMENT '原始事件负载', + status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at DATETIME(3) NOT NULL COMMENT '接收时间(UTC)', + processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)', + PRIMARY KEY (event_id), + KEY idx_container_event_aggregate (aggregate_id, aggregate_version) +) ENGINE=InnoDB COMMENT='海外货柜事件消费Inbox'; + DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns; DELIMITER $$ CREATE PROCEDURE phase3_container_real_unit_columns() @@ -112,6 +132,46 @@ BEGIN ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='customer_id') THEN + ALTER TABLE fl_container_booking ADD COLUMN customer_id BIGINT UNSIGNED NULL + COMMENT '已关联真实订舱指定的客户范围' AFTER ocean_booking_requirement_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='customer_name') THEN + ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL + COMMENT '已关联真实订舱客户名称快照' AFTER customer_id; + END IF; + UPDATE fl_container_booking container_booking + JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id + SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id), + container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name) + WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL + COMMENT '海外仓卸柜完成时间(UTC)' AFTER actual_warehouse_arrival_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='unloading_photo_uploaded_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_photo_uploaded_at DATETIME(3) NULL + COMMENT '海外仓卸柜照片上传成功时间(UTC)' AFTER unloading_completed_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL + COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)' AFTER unloading_photo_uploaded_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN actual_empty_reported_at DATETIME(3) NULL + COMMENT '实际报空时间(UTC)' AFTER estimated_empty_reported_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='empty_report_source_version') THEN + ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL + COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at; + END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no); @@ -753,14 +813,23 @@ BEGIN SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN IF EXISTS (SELECT 1 FROM fl_loading_plan_item item - LEFT JOIN fl_loading_pick_item pick_item - ON pick_item.loading_plan_item_id = item.id - LEFT JOIN fl_loading_pick_task pick_task - ON pick_task.id = pick_item.pick_task_id - AND pick_task.loading_plan_id = OLD.id WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 - AND (pick_task.id IS NULL - OR pick_item.picked_package_count <> item.package_count)) THEN + AND NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id + WHERE pick_item.loading_plan_item_id = item.id + AND pick_task.loading_plan_id = OLD.id + AND pick_task.task_status = 'LOADING' + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) + )) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; END IF; SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); @@ -1053,7 +1122,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -1069,7 +1138,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ diff --git a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql index 8004ba31b..2740d4d5c 100644 --- a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql +++ b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql @@ -10,6 +10,9 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增', items_json JSON NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据', remark VARCHAR(1000) NULL COMMENT '预装备注', + confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID', + confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照', + confirmed_at DATETIME(3) NULL COMMENT '预装确认时间', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建预装清单的操作人ID', created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID', @@ -62,6 +65,13 @@ BEGIN END$$ DELIMITER ; +CALL phase3_split_add_column('fl_preload_plan', 'confirmed_by', + 'BIGINT UNSIGNED NULL COMMENT ''确认预装的操作人ID'' AFTER remark'); +CALL phase3_split_add_column('fl_preload_plan', 'confirmed_by_name', + 'VARCHAR(64) NULL COMMENT ''确认预装的操作人姓名快照'' AFTER confirmed_by'); +CALL phase3_split_add_column('fl_preload_plan', 'confirmed_at', + 'DATETIME(3) NULL COMMENT ''预装确认时间'' AFTER confirmed_by_name'); + CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id', 'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id'); CALL phase3_split_add_column('fl_inbound_order', 'split_level', diff --git a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql index 311cfa4ed..8572d80c8 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -109,14 +109,13 @@ CREATE PROCEDURE assert_truck_appointment_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102150 AND 5102154 - AND permission_code <> CASE id - WHEN 5102150 THEN 'platform:firstleg:truck-appointment:list' - WHEN 5102151 THEN 'platform:firstleg:truck-appointment:list' - WHEN 5102152 THEN 'platform:firstleg:truck-appointment:create' - WHEN 5102153 THEN 'platform:firstleg:truck-appointment:edit' - WHEN 5102154 THEN 'platform:firstleg:truck-appointment:operate' - END) + WHERE (id = 5102150 AND permission_code <> 'platform:firstleg:truck-appointment:list') + OR (id = 5102151 AND permission_code NOT IN ( + 'platform:firstleg:truck-appointment:list', + CONCAT('platform:firstleg:truck-appointment:', 'view'))) + OR (id = 5102152 AND permission_code <> 'platform:firstleg:truck-appointment:create') + OR (id = 5102153 AND permission_code <> 'platform:firstleg:truck-appointment:edit') + OR (id = 5102154 AND permission_code <> 'platform:firstleg:truck-appointment:operate') OR (permission_code LIKE 'platform:firstleg:truck-appointment:%' AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102150 AND 5102154)) ) THEN @@ -148,6 +147,6 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id WHERE p.id=5102151; -UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151; +DELETE FROM iam_permission WHERE id=5102151; USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index ff50fc8de..5fac3399f 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -2,30 +2,33 @@ openapi: 3.0.3 info: title: Carrier Schedule Query API version: 1.0.0 - description: Six-carrier query tasks with a three-day window, at most three attempts, and explicit manual fallback. + description: Phase-4 carrier tracking from domestic outbound confirmation through actual empty return. paths: /api/platform/v1/carrier-schedule-queries/carriers: get: summary: List official carrier sources and compliant access modes + x-permission: platform:firstleg:port-schedule-query:list responses: - '200': {description: MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors} + '200': {description: 'MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors'} /api/platform/v1/carrier-schedule-queries: get: summary: Query carrier schedule tasks + x-permission: platform:firstleg:port-schedule-query:list parameters: - {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}} - {name: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} - {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} - - {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/CarrierCode'}} + - {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/TaskCarrierCode'}} - {name: status, in: query, schema: {$ref: '#/components/schemas/QueryStatus'}} - {name: expired, in: query, schema: {type: boolean}} - {name: keyword, in: query, schema: {type: string, maxLength: 128}} - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} responses: - '200': {description: Query tasks with source, freshness and rate-limit fields} + '200': {description: Query tasks with source and freshness fields; normal execution uses fixed Shanghai slots while retryable technical failures may retry within the current slot} post: summary: Create a query task linked to a real booking and allocated container + x-permission: platform:firstleg:port-schedule-query:create parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -37,31 +40,25 @@ paths: /api/platform/v1/carrier-schedule-queries/{queryId}: parameters: [{$ref: '#/components/parameters/QueryId'}] get: - summary: Get task, immutable attempts and history + summary: Get task, standardized node facts and immutable business history + x-permission: platform:firstleg:port-schedule-query:list responses: - '200': {description: Stable task detail} + '200': + description: Stable task detail + content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} /api/platform/v1/carrier-schedule-queries/{queryId}/history: parameters: [{$ref: '#/components/parameters/QueryId'}] get: summary: Get immutable task history + x-permission: platform:firstleg:port-schedule-query:list responses: - '200': {description: CREATE, ATTEMPT and MANUAL_RESULT history} - /api/platform/v1/carrier-schedule-queries/{queryId}/attempts: - parameters: [{$ref: '#/components/parameters/QueryId'}] - post: - summary: Execute the compliant carrier strategy; never bypass captcha or dynamic pages - parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] - requestBody: - required: true - content: - application/json: - schema: {$ref: '#/components/schemas/AttemptRequest'} - responses: - '200': {description: MANUAL_REQUIRED with coverageGap when no public structured API is available} + '200': {description: Immutable business history; scheduler attempts are not exposed} /api/platform/v1/carrier-schedule-queries/{queryId}/manual-results: parameters: [{$ref: '#/components/parameters/QueryId'}] post: summary: Link a validated manual port-schedule observation without pretending automated success + description: This legacy port-schedule association does not append a standard node and never stops tracking; phase-4 manual facts use /manual-nodes. + x-permission: platform:firstleg:port-schedule-query:manual parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -69,15 +66,42 @@ paths: application/json: schema: {$ref: '#/components/schemas/ManualResultRequest'} responses: - '200': {description: Successful manual fallback and next query time} + '200': + description: Successful manual fallback and next query time + content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} + /api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes: + parameters: [{$ref: '#/components/parameters/QueryId'}] + post: + summary: Append one immutable manual estimated or actual standard-node fact + x-permission: platform:firstleg:port-schedule-query:manual + parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ManualNodeRequest'} + responses: + '200': + description: Idempotent fact append; estimated facts never advance actual progress, while a manual actual fact must have a strictly greater standard-node sequence than latestActualNode and actual EMPTY_RETURNED stops + '409': + description: A0409 when expectedVersion is stale or a manual actual node is equal to or earlier than latestActualNode + content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} components: parameters: QueryId: {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} schemas: CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} + TaskCarrierCode: + type: string + enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER] + description: OTHER is frozen on the ocean booking and creates a manual-only task after outbound confirmation. TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} - QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, SUCCESS]} + QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]} + StandardNode: + type: string + enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT, DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED] + ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]} CreateRequest: type: object required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo] @@ -90,12 +114,6 @@ components: trackingType: {$ref: '#/components/schemas/TrackingType'} trackingNo: {type: string, minLength: 1, maxLength: 128} remark: {type: string, nullable: true, maxLength: 1000} - AttemptRequest: - type: object - required: [expectedVersion] - properties: - expectedVersion: {type: integer, minimum: 0} - reason: {type: string, nullable: true, maxLength: 1000} ManualResultRequest: type: object required: [expectedVersion, portScheduleId, reason] @@ -103,11 +121,119 @@ components: expectedVersion: {type: integer, minimum: 0} portScheduleId: {type: integer, format: int64, minimum: 1} reason: {type: string, minLength: 1, maxLength: 1000} + ManualNodeRequest: + type: object + required: [expectedVersion, standardNode, observedAt, reason] + description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node; when actualAt is present, standardNode must have a strictly greater sequence than latestActualNode. + properties: + expectedVersion: {type: integer, minimum: 0} + standardNode: {$ref: '#/components/schemas/StandardNode'} + observedAt: {type: string, format: date-time} + estimatedAt: {type: string, format: date-time, nullable: true} + actualAt: {type: string, format: date-time, nullable: true} + locationCode: {type: string, maxLength: 32, nullable: true} + locationName: {type: string, maxLength: 128, nullable: true} + reason: {type: string, minLength: 1, maxLength: 1000} + Task: + type: object + required: [id, queryNo, customerId, oceanBookingId, carrierCode, trackingType, trackingNo, status, triggerSource, version] + properties: + id: {type: integer, format: int64} + queryNo: {type: string} + customerId: {type: integer, format: int64} + oceanBookingId: {type: integer, format: int64} + containerBookingId: {type: integer, format: int64, nullable: true} + carrierCode: {$ref: '#/components/schemas/TaskCarrierCode'} + trackingType: {$ref: '#/components/schemas/TrackingType'} + trackingNo: {type: string} + officialUrl: {type: string, nullable: true} + accessMode: + type: string + enum: [PUBLIC_FORM, PUBLIC_WEB_PROXY, PUBLIC_HTML, HCAPTCHA, OFFICIAL_API, MANUAL] + nullable: true + description: MANUAL tasks preserve physical outbound confirmation and accept platform manual node facts without scheduler/provider calls. + status: {$ref: '#/components/schemas/QueryStatus'} + triggerSource: {type: string} + latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true} + latestActualAt: {type: string, format: date-time, nullable: true} + lastSuccessAt: {type: string, format: date-time, nullable: true} + sourceUpdatedAt: {type: string, format: date-time, nullable: true} + expiresAt: {type: string, format: date-time, nullable: true} + resultPortScheduleId: {type: integer, format: int64, nullable: true} + manualReason: + type: string + nullable: true + description: Explicit provider-disabled or unsupported-carrier reason; unsupported outbound carriers retain the booking shippingCompanyId/name here and through oceanBookingId. + lastErrorCode: {type: string, nullable: true} + lastErrorMessage: {type: string, nullable: true} + stoppedAt: {type: string, format: date-time, nullable: true} + stopReason: {type: string, nullable: true} + version: {type: integer, minimum: 0} QueryView: type: object - required: [query, expired, maxAttempts, nextAllowedAt] + required: [query, expired] properties: - query: {type: object, description: Includes source, attemptCount, lastAttemptAt, lastSuccessAt, sourceUpdatedAt and manualReason} + query: {$ref: '#/components/schemas/Task'} expired: {type: boolean} - maxAttempts: {type: integer, enum: [3]} - nextAllowedAt: {type: string, format: date-time} + NodeFact: + type: object + required: [id, providerCode, providerEventId, providerNodeCode, standardNode, nodeSequence, observedAt, rawPayloadHash, createdAt] + properties: + id: {type: integer, format: int64} + providerCode: {type: string, maxLength: 32} + providerEventId: {type: string, maxLength: 128} + providerNodeCode: {type: string, maxLength: 64} + standardNode: {$ref: '#/components/schemas/StandardNode'} + nodeSequence: {type: integer, enum: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]} + observedAt: {type: string, format: date-time} + estimatedAt: {type: string, format: date-time, nullable: true} + actualAt: {type: string, format: date-time, nullable: true} + locationCode: {type: string, maxLength: 32, nullable: true} + locationName: {type: string, maxLength: 128, nullable: true} + sourceReference: {type: string, maxLength: 500, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} + createdAt: {type: string, format: date-time} + ExceptionFact: + type: object + required: [id, providerCode, providerEventId, providerStatus, exceptionCode, active, observedAt, rawPayloadHash, createdAt] + properties: + id: {type: integer, format: int64} + providerCode: {type: string, maxLength: 32} + providerEventId: {type: string, maxLength: 128} + providerStatus: {type: string, maxLength: 128} + exceptionCode: {$ref: '#/components/schemas/ExceptionCode'} + active: {type: boolean, description: true means hold occurred; false means released} + observedAt: {type: string, format: date-time} + locationCode: {type: string, maxLength: 32, nullable: true} + locationName: {type: string, maxLength: 128, nullable: true} + sourceReference: {type: string, maxLength: 500, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} + createdAt: {type: string, format: date-time} + History: + type: object + required: [id, action, createdAt] + properties: + id: {type: integer, format: int64} + action: {type: string, enum: [CREATE, AUTO_CREATE, MANUAL_RESULT, MANUAL_NODE, NODE_OBSERVED, EXCEPTION_OBSERVED, PROVIDER_QUERY]} + fromVersion: {type: integer, nullable: true} + toVersion: {type: integer, nullable: true} + reason: {type: string, nullable: true} + operatorId: {type: integer, format: int64, nullable: true} + operatorName: {type: string, nullable: true} + createdAt: {type: string, format: date-time} + Detail: + type: object + required: [query, nodes, exceptions, histories] + description: Scheduler lease, attempt and internal execution fields are intentionally absent. + properties: + query: {$ref: '#/components/schemas/QueryView'} + nodes: {type: array, items: {$ref: '#/components/schemas/NodeFact'}} + exceptions: {type: array, items: {$ref: '#/components/schemas/ExceptionFact'}} + histories: {type: array, items: {$ref: '#/components/schemas/History'}} + DetailEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/Detail'} diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index c7a74e31f..aa91a88be 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -18,7 +18,7 @@ paths: description: 成功或业务失败 content: application/json: - schema: {$ref: '#/components/schemas/CustomerRuleVersionSummary'} + schema: {$ref: '#/components/schemas/CustomerRuleVersionListApiResponse'} '403': {description: 无权限} /api/platform/v1/fba-pricing/customer-rules/versions: post: @@ -231,6 +231,44 @@ components: VersionId: {name: versionId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: + CustomerRuleVersionListApiResponse: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: array + items: {$ref: '#/components/schemas/CustomerRuleVersionResource'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + CustomerRuleVersionResource: + type: object + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + ruleCode: {type: string} + ruleVersion: {type: integer} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + customerName: {type: string} + businessType: {type: string} + chargeMode: {type: string} + taxMode: {type: string} + conversionFactor: {type: number} + roundingScale: {type: integer} + roundingMode: {type: string} + currency: {type: string} + effectiveFrom: {type: string, format: date-time} + effectiveTo: {type: string, format: date-time, nullable: true} + status: {type: string} + remark: {type: string, nullable: true} + createdBy: {type: string, nullable: true} + createdAt: {type: string, format: date-time, nullable: true} + updatedBy: {type: string, nullable: true} + updatedAt: {type: string, format: date-time, nullable: true} + deleted: {type: boolean, nullable: true} + version: {type: integer, nullable: true} FeeSnapshotApiResponse: type: object required: [code, message, data] @@ -415,7 +453,7 @@ components: required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] properties: expectedFeeVersion: {type: integer, minimum: 1} - chargeableQuantity: {type: number, exclusiveMinimum: 0, description: 计费量,单位沿用当前费用快照的 chargeUnit} + chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit} unitPrice: {type: number, minimum: 0, description: 人工确认单价} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} @@ -432,7 +470,7 @@ components: businessType: {type: string, maxLength: 32} chargeMode: {type: string, enum: [KG, CBM]} taxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]} - conversionFactor: {type: number, exclusiveMinimum: 0, example: 167} + conversionFactor: {type: number, minimum: 0, exclusiveMinimum: true, example: 167} roundingScale: {type: integer, minimum: 0, maximum: 4} roundingMode: {type: string, enum: [UP, DOWN, CEILING, FLOOR, HALF_UP, HALF_DOWN, HALF_EVEN]} currency: {type: string, pattern: '^[A-Z]{3}$'} @@ -454,7 +492,7 @@ components: destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} destinationName: {type: string, maxLength: 128, nullable: true} chargeUnit: {type: string, enum: [KG, CBM]} - unitPrice: {type: number, exclusiveMinimum: 0} + unitPrice: {type: number, minimum: 0, exclusiveMinimum: true} currency: {type: string, pattern: '^[A-Z]{3}$'} additionalFee: {type: number, minimum: 0} effectiveFrom: {type: string, format: date-time} @@ -578,7 +616,7 @@ components: pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} chargeUnit: {type: string, enum: [KG, CBM]} weightFromKg: {type: number, minimum: 0, nullable: true} - unitPrice: {type: number, exclusiveMinimum: 0} + unitPrice: {type: number, minimum: 0, exclusiveMinimum: true} currency: {type: string, pattern: '^[A-Z]{3}$'} sourceSheetName: {type: string, maxLength: 128} sourceRowNumber: {type: integer, minimum: 1} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 9db3d7a28..6b4dc5e8e 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -356,6 +356,16 @@ paths: required: true content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}} responses: {'200': {description: 成功、文件归属不匹配或版本冲突}} + /api/platform/v1/containers/{id}/clearance/document/{fileId}/download: + get: + summary: 下载货柜目的港清关附件 + description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存 + parameters: + - {$ref: '#/components/parameters/ContainerBookingId'} + - {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 附件二进制内容,包含 Content-Disposition 与 Cache-Control no-store} + '404': {description: 当前附件不存在或归属不匹配} /api/platform/v1/containers/{id}/clearance/document/upload: post: summary: 直接选择文件上传或替换货柜目的港清关资料 @@ -1112,7 +1122,6 @@ components: notificationStatus: {type: string, nullable: true} version: {type: integer, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true} - requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from fl_inbound_pickup_snapshot.pickup_at} customerId: {type: integer, format: int64, nullable: true} customerName: {type: string, nullable: true} companyId: {type: integer, format: int64, nullable: true} @@ -1122,6 +1131,7 @@ components: deliveryWarehouseId: {type: integer, format: int64, nullable: true} deliveryWarehouseName: {type: string, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} taxMode: {type: string, nullable: true} @@ -1466,7 +1476,7 @@ components: adjustmentReason: {type: string, minLength: 1, maxLength: 500, nullable: true} OrderApprovedInboundCommand: type: object - required: [tenantId, orderId, inboundCode, customerName, warehouseId] + required: [tenantId, orderId, inboundCode, customerName, warehouseId, fulfillmentType] properties: tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID} orderId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -1480,6 +1490,7 @@ components: deliveryWarehouseName: {type: string, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], description: 独立履约类型;不得复用计费 businessType} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 历史兼容的内部运输供应商快照} @@ -1550,6 +1561,11 @@ components: actualPortArrivalAt: {type: string, format: date-time, nullable: true} estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true} actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true} + unloadingCompletedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜完成时间} + unloadingPhotoUploadedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜照片上传成功时间} + estimatedEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 预计报空时间,由卸柜照片上传成功时间加两天计算} + actualEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 实际报空时间} + emptyReportSourceVersion: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 海外货柜事件聚合版本} planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]} exceptionActive: {type: boolean, description: 最新异常历史动作是否为 MARK} destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} @@ -1805,6 +1821,7 @@ components: deliveryWarehouseId: {type: string, nullable: true} deliveryWarehouseName: {type: string, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} containerMode: {type: string, nullable: true} diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 59c5fbc0d..18b436f15 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -99,6 +99,29 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}} responses: '200': {$ref: '#/components/responses/BusinessDocumentResponse'} + /api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download: + get: + summary: 下载装柜清单出口报关附件 + description: 完成态仍可下载。服务端校验 planId、warehouseId、当前 CUSTOMS_DECLARATION 的 fileId,以及 File 元数据 tenantId=0、businessType=LOADING_CUSTOMS_DECLARATION、businessId=planId;错误 fileId 或归属返回 A0404。响应使用 attachment 文件名并禁止缓存。 + x-permission: platform:firstleg:loading-list:list + parameters: + - {$ref: '#/components/parameters/PlanId'} + - {$ref: '#/components/parameters/WarehouseId'} + - name: fileId + in: path + required: true + description: File 服务长整型文件 ID + schema: {$ref: '#/components/schemas/LongId'} + responses: + '200': + description: 报关附件二进制内容;包含 Content-Disposition,Cache-Control 为 no-store + headers: + Content-Disposition: {schema: {type: string}} + Cache-Control: {schema: {type: string, example: no-store}} + content: + application/octet-stream: + schema: {type: string, format: binary} + '404': {description: 装柜计划、仓库、当前附件或 File 归属不匹配} /api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload: post: summary: 直接选择文件上传或替换装柜清单出口报关文档 diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 363e22b73..85d652016 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -2,7 +2,12 @@ openapi: 3.0.3 info: {title: Ocean Booking API, version: 2.0.0} paths: /api/platform/v1/ocean-bookings: - get: {summary: List DRAFT and RELEASED bookings, responses: {'200': {description: Booking page}}} + get: + summary: List DRAFT and RELEASED bookings + description: containerCount is the sum of quantity across all container requirements for the booking. It is zero when no requirement exists and never represents linked or ACTIVE containers. + responses: + '200': + description: Booking page; each row uses the frozen requirement-total containerCount semantic post: summary: Create an editable DRAFT description: Creates only the booking aggregate and container requirements; no containerNo, container row, or allocation is created. @@ -28,6 +33,23 @@ paths: parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}} responses: {'200': {description: RELEASED detail}} + /api/platform/v1/ocean-bookings/link-candidates: + get: + summary: Page released booking requirement candidates for one container + description: Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates: RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. An empty result is a normal successful page. Final target-container mutability is revalidated by the link command. + x-permission: platform:firstleg:ocean-booking:list + parameters: + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}} + - {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}} + - {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: Business envelope containing a bounded candidate page + content: + application/json: + schema: {$ref: '#/components/schemas/LinkCandidateResponse'} /api/platform/v1/ocean-bookings/channels: get: summary: List ACTIVE channels for an origin warehouse @@ -58,6 +80,11 @@ components: billOfLadingNo: {type: string, nullable: true} shippingCompanyId: {type: integer, format: int64, nullable: true} shippingCompanyName: {type: string, nullable: true} + carrierCode: + type: string + enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER] + nullable: true + description: Frozen standard carrier code. Required before RELEASE; OTHER means manual carrier-node tracking. bookingAgentId: type: integer format: int64 @@ -94,6 +121,46 @@ components: channelName: {type: string} priceModel: {type: string} pricingTaxModes: {type: array, items: {type: string}} + LinkCandidate: + type: object + required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName] + properties: + oceanBookingId: {type: string, pattern: '^[0-9]+$'} + bookingNo: {type: string} + requirementId: {type: string, pattern: '^[0-9]+$'} + containerType: {type: string} + capacityCbm: {type: number} + maxWeightKg: {type: number} + requiredQuantity: {type: integer, minimum: 1} + allocatedQuantity: {type: integer, format: int64, minimum: 0} + remainingQuantity: {type: integer, format: int64, minimum: 1} + customerName: {type: string} + warehouseName: {type: string} + channelName: {type: string} + LinkCandidatePage: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/LinkCandidate'}} + page: {type: integer, format: int64, minimum: 1} + size: {type: integer, format: int64, minimum: 1} + total: {type: integer, format: int64, minimum: 0} + pages: {type: integer, format: int64, minimum: 0} + LinkCandidateResponse: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/LinkCandidatePage'} + OceanBookingListRow: + type: object + properties: + containerCount: + type: integer + format: int64 + minimum: 0 + description: Sum of all container requirement quantities; independent of allocation, linking or unlinking. Detail: type: object required: [oceanBooking, parties, containerRequirements, containers, histories] diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index 2b7420a4d..47bf7403f 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -36,6 +36,12 @@ paths: description: '权限:platform:firstleg:container:list;Firstleg代理File内部下载,不向调用方暴露内部令牌' parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}} + /api/platform/v1/containers/{id}/clearance/document/{fileId}/download: + get: + operationId: downloadContainerDestinationClearance + description: '权限:platform:firstleg:customs:manage;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载' + parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}} components: parameters: ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}} @@ -51,7 +57,7 @@ components: container: {$ref: '#/components/schemas/Container'} loadingPlanId: {type: integer, format: int64} pda: {$ref: '#/components/schemas/PdaSummary'} - documents: {type: array, items: {$ref: '#/components/schemas/Document'}} + documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version', items: {$ref: '#/components/schemas/Document'}} exception: {$ref: '#/components/schemas/ExceptionState'} exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}} @@ -59,7 +65,7 @@ components: PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}} Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}} - Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer}, completed: {type: boolean}}} + Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, CUSTOMS_DECLARATION, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}} ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}} ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index 60e832d45..3b70a9548 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -34,7 +34,7 @@ paths: content: application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}} responses: - '200': {description: Business envelope; ownership, type and version failures use business error codes} + '200': {description: 'Business envelope; ownership, type and version failures use business error codes'} /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda: get: summary: Download one loading evidence photo for the active PDA worker diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index db431dac1..364c0b142 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -1,84 +1,117 @@ openapi: 3.0.3 info: - title: Phase 3 Domestic Outbound Dispatch API - version: 1.0.0 + title: Phase 3 Container-level Domestic Outbound Dispatch API + version: 2.0.0 paths: /api/platform/v1/first-leg/outbound-dispatches: get: - summary: Query outbound aggregates by order, inbound or container + summary: Query the outbound aggregate for one container booking + description: Returns one container-level aggregate containing every real inbound binding. No per-inbound partial outbound aggregate is exposed. + x-permission: platform:firstleg:outbound-dispatch:list parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdQuery'} responses: '200': - description: Business envelope containing matching aggregates, each with stable cabinetNo and real containerNo + description: Business envelope containing a single matching aggregate in a list content: application/json: schema: type: object properties: data: {type: array, items: {$ref: '#/components/schemas/Aggregate'}} - /api/platform/v1/first-leg/outbound-dispatches/detail: + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}: get: - summary: Query one aggregate and its unified timeline + summary: Query one container-level aggregate and unified timeline + x-permission: platform:firstleg:outbound-dispatch:view parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdPath'} responses: '200': - description: Business envelope containing aggregate detail + description: Business envelope containing all inbound bindings and one visible dispatch version content: application/json: schema: type: object properties: - data: - type: object - properties: - aggregate: {$ref: '#/components/schemas/Aggregate'} - /api/platform/v1/first-leg/outbound-dispatches/check-gate: + data: {$ref: '#/components/schemas/Detail'} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: get: - summary: Evaluate the fail-closed domestic outbound gate + summary: Evaluate every inbound binding in the fail-closed container outbound gate + description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from the current container CUSTOMS_DECLARATION whose completedAt must be non-null; upload alone remains blocked and fl_customs_case is not an alternative gate fact. A missing standalone sailing schedule is reference-only and never affects gate.passed. + x-permission: platform:firstleg:outbound-dispatch:check parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdPath'} responses: - '200': {description: Business envelope containing every gate item} - /api/platform/v1/first-leg/outbound-dispatches/confirm-dispatch: + '200': {description: Business envelope containing the container gate and per-inbound gate results} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/confirm: post: - summary: Confirm domestic dispatch and enter WAITING_FOR_LOADING + summary: Atomically confirm domestic departure for the entire container + description: Locks every real inbound binding, requires every gate to pass, advances all technical rows in one transaction, records one visible history, and creates the CarrierScheduleQuery/DEPARTED_WAREHOUSE fact once. No partial success is possible. + x-permission: platform:firstleg:outbound-dispatch:confirm parameters: + - {$ref: '#/components/parameters/ContainerBookingIdPath'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} responses: - '200': {description: Business envelope; gate or version failures use business error code} - /api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch: + '200': {description: Same key and same payload replays the single container-level result; stale version or changed payload returns A0409} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/reverse: post: - summary: Reverse WAITING_FOR_LOADING to LOADED + summary: Atomically reverse the entire container before any actual shipping node exists + description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so an outbound fact can never coexist with READY_FOR_DISPATCH. + x-permission: platform:firstleg:outbound-dispatch:reverse parameters: + - {$ref: '#/components/parameters/ContainerBookingIdPath'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} responses: - '200': {description: Business envelope containing reverted aggregate} + '200': {description: Business envelope containing the reverted container aggregate} components: parameters: - OrderId: {name: orderId, in: query, schema: {type: integer, format: int64, minimum: 1}} - InboundId: {name: inboundId, in: query, schema: {type: integer, format: int64, minimum: 1}} - ContainerBookingId: {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} - IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + ContainerBookingIdQuery: + name: containerBookingId + in: query + required: true + schema: {type: integer, format: int64, minimum: 1} + ContainerBookingIdPath: + name: containerBookingId + in: path + required: true + schema: {type: integer, format: int64, minimum: 1} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string, maxLength: 128} schemas: Aggregate: type: object - required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus, containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, gate] + required: [containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, inbounds, gate] + properties: + containerBookingId: {type: integer, format: int64} + cabinetNo: {type: string, description: Stable cabinet number} + containerNo: {type: string, nullable: true, description: Real container number} + loadingPlanId: {type: integer, format: int64} + loadingPlanStatus: {type: string} + oceanBookingId: {type: integer, format: int64, nullable: true} + oceanBookingStatus: {type: string, nullable: true, description: Booking reference retained independently from the non-blocking standalone sailing schedule} + truckAppointmentId: {type: integer, format: int64, nullable: true, description: Reference-only truck appointment; absence never blocks manual outbound confirmation} + truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status; PENDING or any non-terminal state never affects gate.passed} + dispatchStatus: {type: string, description: One visible container-level status} + version: {type: integer, minimum: 0, description: One optimistic-lock version shared by all technical inbound rows} + inbounds: + type: array + minItems: 1 + items: {$ref: '#/components/schemas/InboundBinding'} + gate: {$ref: '#/components/schemas/ContainerGateResult'} + InboundBinding: + type: object + required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus] properties: orderId: {type: integer, format: int64} orderNo: {type: string} @@ -88,25 +121,35 @@ components: customerId: {type: integer, format: int64, nullable: true} customerName: {type: string, nullable: true} inboundStatus: {type: string} - containerBookingId: {type: integer, format: int64} - cabinetNo: {type: string, description: 稳定柜次号} - containerNo: {type: string, nullable: true, description: 真实集装箱号} - loadingPlanId: {type: integer, format: int64} - loadingPlanStatus: {type: string} pickTaskId: {type: integer, format: int64, nullable: true} pickTaskStatus: {type: string, nullable: true} - oceanBookingId: {type: integer, format: int64, nullable: true} - oceanBookingStatus: {type: string, nullable: true} - truckAppointmentId: {type: integer, format: int64, nullable: true} - truckAppointmentStatus: {type: string, nullable: true} - dispatchStatus: {type: string} - version: {type: integer, minimum: 0} - gate: {type: object} + ContainerGateResult: + type: object + required: [passed, inbounds, checkedAt] + properties: + passed: {type: boolean} + inbounds: + type: array + items: + type: object + required: [inboundId, inboundCode, gate] + properties: + inboundId: {type: integer, format: int64} + inboundCode: {type: string} + gate: {type: object} + checkedAt: {type: string, format: date-time} + Detail: + type: object + required: [aggregate, timeline] + properties: + aggregate: {$ref: '#/components/schemas/Aggregate'} + timeline: + type: array + description: Unified immutable facts; a container confirm or reverse contributes one visible history item. + items: {type: object} ActionRequest: type: object - required: [inboundId, containerBookingId, expectedVersion] + required: [expectedVersion] properties: - inboundId: {type: integer, format: int64, minimum: 1} - containerBookingId: {type: integer, format: int64, minimum: 1} expectedVersion: {type: integer, minimum: 0} remark: {type: string, maxLength: 500} diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index eef1c0492..165d36799 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -93,7 +93,7 @@ paths: required: true content: {application/json: {schema: {$ref: '#/components/schemas/BatchRequest'}}} responses: - '200': {description: Completed task, identical replay result, or A0409 when any snapshot changed} + '200': {description: 'Completed task, identical replay result, or A0409 when any snapshot changed'} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/review: post: summary: 人工确认装柜完成的兼容入口 diff --git a/firstleg/src/main/resources/openapi/port-schedules-v1.yaml b/firstleg/src/main/resources/openapi/port-schedules-v1.yaml index 4016226a5..d96d74796 100644 --- a/firstleg/src/main/resources/openapi/port-schedules-v1.yaml +++ b/firstleg/src/main/resources/openapi/port-schedules-v1.yaml @@ -7,6 +7,7 @@ paths: /api/platform/v1/port-schedules: get: summary: Query schedules by customer, ocean booking, allocated container or expiry + x-permission: platform:firstleg:port-schedule:list parameters: - {name: customerId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: oceanBookingId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -20,6 +21,7 @@ paths: description: HTTP 200 business envelope containing a page of schedule views post: summary: Create a source observation linked to a real ocean booking + x-permission: platform:firstleg:port-schedule:create parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -32,10 +34,12 @@ paths: parameters: [{$ref: '#/components/parameters/PortScheduleId'}] get: summary: Get schedule and immutable history + x-permission: platform:firstleg:port-schedule:list responses: '200': {description: Schedule detail with computed expired flag} put: summary: Update source observation with optimistic locking + x-permission: platform:firstleg:port-schedule:edit parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -48,6 +52,7 @@ paths: parameters: [{$ref: '#/components/parameters/PortScheduleId'}] get: summary: Query immutable source observation history + x-permission: platform:firstleg:port-schedule:list responses: '200': {description: Ordered CREATE and UPDATE history records} components: diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 4a953b89d..0d2355f8f 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -8,7 +8,7 @@ paths: get: summary: Query truck appointments parameters: - - {name: customerId, in: query, description: Filter by customer, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: customerId, in: query, description: 'Filter by customer', schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: '200': description: Paged truck appointments with stable booking/container snapshots and attachment count @@ -29,13 +29,13 @@ paths: application/json: schema: {$ref: '#/components/schemas/SaveRequest'} responses: - '200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} + '200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} /api/platform/v1/truck-appointments/{id}: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query a truck appointment responses: - '200': {description: Appointment detail, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} + '200': {description: 'Appointment detail', content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} put: summary: Update an editable truck appointment with optimistic locking requestBody: @@ -84,20 +84,20 @@ paths: get: summary: Query immutable appointment status history responses: - '200': {description: History entries including action, from/to status, operator, reason and time} + '200': {description: 'History entries including action, from/to status, operator, reason and time'} /api/platform/v1/truck-appointments/{id}/attachments: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query attachment references owned by the appointment description: 'Requires platform:firstleg:truck-appointment:list.' responses: - '200': {description: Attachment references, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} + '200': {description: 'Attachment references', content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} post: summary: Reference a file already owned by the appointment customer and business scope description: 'Requires platform:firstleg:truck-appointment:edit. The file must belong to customerId + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId.' requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AttachmentRequest'}}}} responses: - '200': {description: Created attachment reference, content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} + '200': {description: 'Created attachment reference', content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} /api/platform/v1/truck-appointments/{id}/attachments/upload: parameters: [{$ref: '#/components/parameters/AppointmentId'}] post: @@ -181,8 +181,8 @@ components: required: [customerId, carrierId, plannedLoadingAt, expectedVersion] properties: outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true} - oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.} - containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE container association. When supplied, customer and warehouse must match.} + oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.'} + containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional ACTIVE container association. When supplied, customer and warehouse must match.'} loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.} warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.} customerId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE IAM customer used for isolation and attachment ownership.} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java index 30474a894..c9ca3a8af 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java @@ -20,7 +20,9 @@ class BaiyueFirstlegApplicationContractTest { .contains("com.baiyue.firstleg.outbound.dispatch.mapper") .contains("com.baiyue.firstleg.portschedule.mapper") .contains("com.baiyue.firstleg.customs.mapper") - .contains("com.baiyue.firstleg.customerstatistics.mapper"); + .contains("com.baiyue.firstleg.customerstatistics.adapter.out.persistence") + .contains("com.baiyue.firstleg.pricebookquery.adapter.out.persistence") + .doesNotContain("com.baiyue.firstleg.customerstatistics.mapper"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java new file mode 100644 index 000000000..b22423e5f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.client; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EventBridgeClientDeploymentContractTest { + private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); + + @Test + void firstlegBridgeTargetComesFromEnvironmentWithoutLoopbackFallback() throws Exception { + String application = Files.readString(REPOSITORY_ROOT.resolve("firstleg/src/main/resources/application.yml")); + String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/firstleg-service.yml")); + + assertTrue(application.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:}")); + assertFalse(application.contains("LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1")); + assertTrue(nacos.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}")); + } + + @Test + void cnComposeForwardsTargetAndPublishesStableNetworkAlias() throws Exception { + String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); + + assertTrue(compose.contains("LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL}")); + assertTrue(compose.contains("SERVER_PORT: 9312")); + assertTrue(compose.contains("aliases: [event-bridge-cn]")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index d94c7e2b7..33327afb2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -33,6 +33,7 @@ class ContainerControllerContractTest { assertPermission("logisticsDocument", "platform:firstleg:container:list"); assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit"); assertPermission("downloadDocument", "platform:firstleg:container:list"); + assertPermission("downloadClearance", "platform:firstleg:customs:manage"); } @Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception { @@ -40,6 +41,8 @@ class ContainerControllerContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:", "/documents/{documentType}/{fileId}/download:", "ExceptionRequest", + "/clearance/document/{fileId}/download:", + "DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER", "enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:", "/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:", "AWAITING_REVIEW", "version: {type: integer, minimum: 0}", @@ -77,6 +80,8 @@ class ContainerControllerContractTest { .doesNotContain("tenantId") .doesNotContain("accountId"); assertThat(openapi) + .contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:") + .contains("Content-Disposition 与 Cache-Control no-store") .contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]") .contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus") .contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerOverviewTransportContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerOverviewTransportContractTest.java new file mode 100644 index 000000000..1d562c4ec --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerOverviewTransportContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.firstleg.dto.ContainerOverviewDtos.ContainerBookingEntityView; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerOverviewTransportContractTest { + @Test + void overviewPublishesMaritimeAndEmptyReportFacts() { + assertThat(Arrays.stream(ContainerBookingEntityView.class.getRecordComponents()).map(value -> value.getName())) + .contains("billOfLadingNo", "shippingCompanyId", "shippingCompanyName", "departurePortCode", + "departurePortName", "destinationPortCode", "destinationPortName", "estimatedSailingAt", + "actualSailingAt", "estimatedPortArrivalAt", "actualPortArrivalAt", + "estimatedWarehouseArrivalAt", "actualWarehouseArrivalAt", "unloadingCompletedAt", + "unloadingPhotoUploadedAt", "estimatedEmptyReportedAt", "actualEmptyReportedAt", + "emptyReportSourceVersion"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index 956e14f4a..a3362b6cc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -4,9 +4,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.web.bind.annotation.RequestParam; +import org.yaml.snakeyaml.Yaml; import java.nio.charset.StandardCharsets; import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -82,9 +85,57 @@ class FbaPricingContractTest { .doesNotContain("format: int64"); } + @Test + @DisplayName("客户规则列表响应锁定数组包络和实体核心字段") + void freezesCustomerRuleVersionListResponse() throws Exception { + String source = new ClassPathResource("openapi/fba-pricing-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map api = map(new Yaml().load(source)); + Map paths = map(api.get("paths")); + Map get = map(map(paths.get( + "/api/platform/v1/fba-pricing/customer-rules")).get("get")); + Map success = map(map(get.get("responses")).get("200")); + Map responseSchema = map(map(map(success.get("content")) + .get("application/json")).get("schema")); + assertThat(responseSchema).containsEntry( + "$ref", "#/components/schemas/CustomerRuleVersionListApiResponse"); + + Map schemas = map(map(api.get("components")).get("schemas")); + Map envelope = map(schemas.get("CustomerRuleVersionListApiResponse")); + assertThat(list(envelope.get("required"))).containsExactly("code", "message", "data", "timestamp"); + Map envelopeProperties = map(envelope.get("properties")); + assertThat(envelopeProperties).containsOnlyKeys( + "code", "message", "data", "requestId", "traceId", "timestamp"); + Map data = map(envelopeProperties.get("data")); + assertThat(data).containsEntry("type", "array"); + assertThat(map(data.get("items"))).containsEntry( + "$ref", "#/components/schemas/CustomerRuleVersionResource"); + + Map resource = map(schemas.get("CustomerRuleVersionResource")); + assertThat(map(resource.get("properties"))).containsOnlyKeys( + "id", "tenantId", "ruleCode", "ruleVersion", "customerId", "customerName", + "businessType", "chargeMode", "taxMode", "conversionFactor", "roundingScale", + "roundingMode", "currency", "effectiveFrom", "effectiveTo", "status", "remark", + "createdBy", "createdAt", "updatedBy", "updatedAt", "deleted", "version"); + for (String longField : List.of("id", "tenantId", "customerId", "createdBy", "updatedBy")) { + assertThat(map(map(resource.get("properties")).get(longField))).containsEntry("type", "string"); + } + assertThat(source).doesNotContain("CustomerRuleVersionSummary"); + } + private static Method method(String name) { return java.util.Arrays.stream(PlatformFbaMasterDataController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)) .findFirst().orElseThrow(); } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List list(Object value) { + return (List) value; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index d228c6137..28be884a7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -16,6 +16,8 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.http.ResponseEntity; import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; @@ -41,6 +43,8 @@ class LoadingListControllerContractTest { ConfirmedQuery.class); authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); authority("declaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); + authority("downloadDeclaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, + long.class, long.class, long.class); authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, long.class, long.class, String.class, DocumentAttachRequest.class); authority("uploadDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, @@ -88,6 +92,8 @@ class LoadingListControllerContractTest { .contains("oceanBookingStatus") .contains("pdaTaskId") .contains("cabinetNo") + .contains("LOADING_CUSTOMS_DECLARATION") + .contains("Cache-Control 为 no-store") .doesNotContain("internalContainerNo") .doesNotContain("rollback") .doesNotContain("delete:") @@ -118,6 +124,18 @@ class LoadingListControllerContractTest { assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull(); } + @Test + void customsDownloadUsesStablePlanPathAndBinaryResponse() throws Exception { + Method method = PlatformLoadingListController.class.getDeclaredMethod("downloadDeclaration", + AuthenticatedUser.class, long.class, long.class, long.class); + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly( + "/loading-lists/{planId}/customs/document/{fileId}/download"); + assertThat(method.getReturnType()).isEqualTo(ResponseEntity.class); + assertThat(method.getParameters()[1].getName()).isEqualTo("planId"); + assertThat(method.getParameters()[2].getName()).isEqualTo("fileId"); + assertThat(method.getParameters()[3].getName()).isEqualTo("warehouseId"); + } + @Test void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") @@ -134,6 +152,7 @@ class LoadingListControllerContractTest { "/api/platform/v1/first-leg/loading-lists/{planId}", "/api/platform/v1/first-leg/loading-lists/{planId}/customs", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document", + "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete"); Map components = stringMap(document.get("components")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index cccb0a153..5a9a269a2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -6,7 +6,7 @@ import com.baiyue.firstleg.domain.PriceModel; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.domain.PricingDestinationType; import com.baiyue.firstleg.dto.PriceBookDtos; -import com.baiyue.firstleg.mapper.PriceBookQueryMapper; +import com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index d18b7a6e3..8665093a8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -37,12 +37,17 @@ class UnifiedInboundOpenApiContractTest { assertThat(stringMap(inbound.get("properties"))).containsKeys( "id", "tenantId", "orderId", "inboundCode", "processStatus", "version", "customerId", "warehouseId", "deliveryWarehouseId", "totalPackageCount", - "businessType", "channelCode", "pricingTaxMode", "containerMode", "destinationType", + "businessType", "fulfillmentType", "channelCode", "pricingTaxMode", "containerMode", "destinationType", "holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm", "volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount", "inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo", "cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt", "actualPortArrivalAt", "createdByName", "createdAt", "updatedAt"); + + Map approval = stringMap(schemas.get("OrderApprovedInboundCommand")); + assertThat(stringList(approval.get("required"))).contains("fulfillmentType"); + Map fulfillment = stringMap(stringMap(approval.get("properties")).get("fulfillmentType")); + assertThat(stringList(fulfillment.get("enum"))).containsExactly("STANDARD", "ONE_CLICK_DROPSHIP"); } @SuppressWarnings("unchecked") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java new file mode 100644 index 000000000..252da501c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java @@ -0,0 +1,80 @@ +package com.baiyue.firstleg.customerstatistics; + +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class CustomerStatisticsArchitectureTest { + private static final String FIRSTLEG = "com.baiyue.firstleg.."; + private static final String CUSTOMER_STATISTICS = "com.baiyue.firstleg.customerstatistics.."; + private static final String APPLICATION = "com.baiyue.firstleg.customerstatistics.application.."; + private static final String OUTPUT_PORT = "com.baiyue.firstleg.customerstatistics.application.port.out.."; + private static final String PERSISTENCE = "com.baiyue.firstleg.customerstatistics.adapter.out.persistence.."; + private static final String WEB = "com.baiyue.firstleg.customerstatistics.adapter.in.web.."; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.baiyue.firstleg"); + + @Test + void applicationModelAndPortsStayFreeOfWebAndPersistenceFrameworks() { + noClasses().that().resideInAPackage(APPLICATION) + .should().dependOnClassesThat().resideInAnyPackage( + "org.springframework.web..", + "org.apache.ibatis..", + "com.baomidou.mybatisplus..") + .check(PRODUCTION_CLASSES); + } + + @Test + void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() { + classes().that().resideInAPackage(APPLICATION) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION))) + .check(PRODUCTION_CLASSES); + } + + @Test + void persistenceAdapterCollaboratesThroughTheApplicationOutputPort() { + classes().that().resideInAPackage(PERSISTENCE) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(CUSTOMER_STATISTICS).or(resideInAPackage(OUTPUT_PORT))) + .andShould().beAssignableTo(CustomerTransitStatisticsReader.class) + .check(PRODUCTION_CLASSES); + } + + @Test + void webAdapterOnlyUsesApplicationAndScopeGuardWithinFirstleg() { + DescribedPredicate scopeGuard = new DescribedPredicate<>("FirstlegScopeGuard") { + @Override + public boolean test(JavaClass javaClass) { + return javaClass.getName().equals(FirstlegScopeGuard.class.getName()); + } + }; + classes().that().resideInAPackage(WEB) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG) + .or(resideInAPackage(APPLICATION)) + .or(resideInAPackage(WEB)) + .or(scopeGuard)) + .check(PRODUCTION_CLASSES); + } + + @Test + void otherFirstlegProductionPackagesDoNotDependOnCustomerStatistics() { + noClasses().that().resideOutsideOfPackage(CUSTOMER_STATISTICS) + .should().dependOnClassesThat().resideInAPackage(CUSTOMER_STATISTICS) + .check(PRODUCTION_CLASSES); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java index 33463262f..33793badc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java @@ -1,8 +1,8 @@ package com.baiyue.firstleg.customerstatistics; -import com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController; +import com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java index e7028ebb9..04bf83ec8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java @@ -1,8 +1,8 @@ package com.baiyue.firstleg.customerstatistics; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -19,12 +19,12 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CustomerTransitStatisticsServiceTest { - @Mock CustomerTransitStatisticsMapper mapper; + @Mock CustomerTransitStatisticsReader reader; @InjectMocks CustomerTransitStatisticsService service; @Test void preservesDecimalTeuAndUnknownCount() { var asOf = LocalDateTime.of(2026, 8, 13, 12, 0); - when(mapper.summarize(88L)).thenReturn(new CustomerTransitStatisticsMapper.SummaryRow( + when(reader.summarize(88L)).thenReturn(new CustomerTransitStatisticsReader.SummaryProjection( 88L, 4L, new BigDecimal("6.25"), 1L, asOf)); var result = service.summarize(88L); @@ -35,7 +35,7 @@ class CustomerTransitStatisticsServiceTest { assertThat(result.unknownContainerCount()).isEqualTo(1); assertThat(result.asOf()).isEqualTo(asOf); assertThat(result.definition()).contains("20*=1", "40*=2", "45*=2.25", "未知箱型"); - verify(mapper).summarize(88L); + verify(reader).summarize(88L); } @Test void rejectsInvalidCustomerBeforeQuery() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index 594eaf569..dc499e353 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.entity.ContainerDocumentEntity; -import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.customs.mapper.CustomsCaseMapper; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class CustomsDispatchGateContributorTest { - @Mock LoadingListRepository repository; + @Mock CustomsRepository repository; @InjectMocks CustomsDispatchGateContributor contributor; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @@ -22,16 +22,36 @@ class CustomsDispatchGateContributorTest { .isEqualTo("INVALID_CUSTOMER_SCOPE"); } - @Test void passesOnlyCompletedSimpleDeclaration() { - ContainerDocumentEntity value = new ContainerDocumentEntity(); - when(repository.document(81L, "CUSTOMS_DECLARATION")).thenReturn(value); - assertThat(contributor.assess(context()).passed()).isFalse(); - value.setCompletedAt(java.time.LocalDateTime.now()); - assertThat(contributor.assess(context())) - .satisfies(item -> { - assertThat(item.passed()).isTrue(); - assertThat(item.requirement()).isEqualTo("COMPLETED"); - }); + @Test void uploadedButIncompleteDeclarationRemainsBlocked() { + when(repository.loadingDeclarationCompleted(81L)).thenReturn(false); + + assertThat(contributor.assess(context())).satisfies(item -> { + assertThat(item.passed()).isFalse(); + assertThat(item.actual()).isEqualTo("MISSING"); + assertThat(item.requirement()).isEqualTo("COMPLETED"); + }); + } + + @Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() { + when(repository.loadingDeclarationCompleted(81L)).thenReturn(true); + + assertThat(contributor.assess(context())).satisfies(item -> { + assertThat(item.passed()).isTrue(); + assertThat(item.actual()).isEqualTo("COMPLETED"); + assertThat(item.requirement()).isEqualTo("COMPLETED"); + }); + verify(repository).loadingDeclarationCompleted(81L); + verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong()); + } + + @Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception { + String sql = String.join(" ", CustomsCaseMapper.class + .getMethod("completedLoadingDeclaration", long.class) + .getAnnotation(org.apache.ibatis.annotations.Select.class).value()); + assertThat(sql) + .contains("FROM fl_container_document", "container_booking_id=#{containerBookingId}") + .contains("document_type='CUSTOMS_DECLARATION'", "completed_at IS NOT NULL") + .doesNotContain("fl_customs_case", "CLEARANCE_COMPLETED"); } private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index df43c1be5..df0b2fca1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -20,6 +20,7 @@ class PlatformOceanBookingControllerContractTest { .containsExactly("/api/platform/v1/ocean-bookings"); Map permissions = Map.ofEntries( Map.entry("page", "platform:firstleg:ocean-booking:list"), + Map.entry("linkCandidates", "platform:firstleg:ocean-booking:list"), Map.entry("channels", "platform:firstleg:ocean-booking:list"), Map.entry("detail", "platform:firstleg:ocean-booking:list"), Map.entry("create", "platform:firstleg:ocean-booking:create"), @@ -30,7 +31,8 @@ class PlatformOceanBookingControllerContractTest { Method method = method(name); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")) { + if (!name.equals("page") && !name.equals("detail") && !name.equals("channels") + && !name.equals("linkCandidates")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); @@ -57,6 +59,43 @@ class PlatformOceanBookingControllerContractTest { .doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo"); } + @Test + void linkCandidatesUsesDedicatedGetPathAndStablePageContract() throws Exception { + Method method = method("linkCandidates"); + assertThat(method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()) + .containsExactly("/link-candidates"); + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("/api/platform/v1/ocean-bookings/link-candidates:") + .contains("x-permission: platform:firstleg:ocean-booking:list") + .contains("warehouseId", "channelCode", "containerType") + .contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity") + .contains("Capacity and max weight are derived display snapshots and never eligibility fields") + .contains("Returns only linkable requirement candidates") + .contains("An empty result is a normal successful page") + .contains("booking detail calls are not used") + .doesNotContain("mismatchReasons", "disabledReason"); + } + + @Test + void openApiDefinesContainerCountAsRequirementQuantityTotal() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("containerCount is the sum of quantity across all container requirements") + .contains("never represents linked or ACTIVE containers") + .contains("Sum of all container requirement quantities; independent of allocation, linking or unlinking."); + } + + @Test + void openApiFreezesStandardCarrierCodeOnBooking() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("carrierCode:") + .contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]") + .contains("Required before RELEASE") + .contains("OTHER means manual carrier-node tracking"); + } + private static Method method(String name) { return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) .filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 050195e76..d4557b21e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.oceanbooking.repository; import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper; +import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; @@ -20,7 +22,10 @@ class OceanBookingMapperContractTest { .contains("booking.status=#{query.status}") .contains("booking.booking_no LIKE") .contains("party.party_type='SHIPPER'", "party.party_type='CONSIGNEE'") - .contains("fl_ocean_booking_container_allocation", "container.record_status='ACTIVE'", "container_count") + .contains("COALESCE((SELECT SUM(requirement.quantity)") + .contains("FROM fl_ocean_booking_container_requirement requirement") + .contains("requirement.ocean_booking_id=booking.id),0) container_count") + .doesNotContain("fl_ocean_booking_container_allocation allocation\n JOIN fl_container_booking") .contains("ORDER BY booking.updated_at DESC,booking.id DESC"); } @@ -36,4 +41,39 @@ class OceanBookingMapperContractTest { .contains("status='DRAFT' AND version=#{version}") .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED"); } + + @Test + void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception { + String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates", + com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("booking.status='RELEASED'") + .contains("booking.warehouse_id=#{query.warehouseId}") + .contains("booking.channel_code=#{query.channelCode}") + .contains("requirement.container_type=#{query.containerType}") + .contains("FROM fl_ocean_booking_container_allocation") + .contains("container.record_status='ACTIVE'") + .contains("container.ocean_booking_id=allocation.ocean_booking_id") + .contains("container.ocean_booking_requirement_id=allocation.requirement_id") + .contains("GROUP BY allocation.ocean_booking_id,allocation.requirement_id") + .contains("requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity") + .contains("requirement.quantity>COALESCE(allocated.allocated_quantity,0)") + .doesNotContain("SELECT booking.*", + "requirement.capacity_cbm=#{query.capacityCbm}", + "requirement.max_weight_kg=#{query.maxWeightKg}"); + } + + @Test + void finalLinkLimitCountsOnlyActiveContainersStillPointingAtTheSameRequirement() throws Exception { + String sql = String.join("\n", OceanBookingContainerAllocationMapper.class + .getDeclaredMethod("countActiveLinked", long.class, long.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("container.record_status='ACTIVE'") + .contains("container.ocean_booking_id=allocation.ocean_booking_id") + .contains("container.ocean_booking_requirement_id=allocation.requirement_id") + .contains("allocation.ocean_booking_id=#{oceanBookingId}") + .contains("allocation.requirement_id=#{requirementId}"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java index 9f2df34e0..5d73959ff 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java @@ -24,6 +24,8 @@ class OceanBookingSqlContractTest { .contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") .contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'") .contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'") + .contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码") + .contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')") .contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'") .contains("REFERENCES fl_container_booking(id)") .contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 06019bc33..2b846c7ba 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.oceanbooking.service; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.client.WarehouseSupplierClient; @@ -9,8 +10,8 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementE import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator; import com.fasterxml.jackson.databind.ObjectMapper; @@ -33,7 +34,7 @@ import static org.mockito.Mockito.*; class OceanBookingServiceTest { @Mock private OceanBookingRepository repository; - @Mock private PriceBookQueryRepository priceBooks; + @Mock private PriceBookQueryUseCase priceBooks; @Mock private WarehouseSupplierSnapshotService suppliers; @Mock private WarehouseMasterDataClient warehouses; @Mock private FirstlegDocumentNumberGenerator documentNumbers; @@ -170,6 +171,60 @@ class OceanBookingServiceTest { verify(repository, times(2)).page(any()); } + @Test + void pagePreservesRequirementTotalContainerCountsForSingleMultipleAndEmptyDrafts() { + OceanBookingEntity one45Hc = booking("RELEASED", 1); one45Hc.setContainerCount(1L); + OceanBookingEntity multipleTypes = booking("RELEASED", 1); multipleTypes.setId(2L); + multipleTypes.setContainerCount(4L); + OceanBookingEntity emptyDraft = booking("DRAFT", 0); emptyDraft.setId(3L); + emptyDraft.setContainerCount(0L); + when(repository.page(any())).thenReturn(new PageResult<>( + List.of(one45Hc, multipleTypes, emptyDraft), 1, 20, 3, 1)); + + PageResult result = service.page(new Query(null, null, null, 1, 20)); + + assertThat(result.records()).extracting(OceanBookingEntity::getContainerCount) + .containsExactly(1L, 4L, 0L); + } + + @Test + void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() { + LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC", + new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2, + "客户A", "深圳中转仓", "美西海运"); + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); + + PageResult result = service.linkCandidates(new LinkCandidateQuery(701L, + " SEA-US ", " 40HC ", null, null)); + + assertThat(result.records()).containsExactly(candidate); + assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); + ArgumentCaptor query = ArgumentCaptor.forClass(LinkCandidateQuery.class); + verify(repository).linkCandidates(query.capture()); + assertThat(query.getValue().channelCode()).isEqualTo("SEA-US"); + assertThat(query.getValue().containerType()).isEqualTo("40HC"); + assertThat(query.getValue().page()).isEqualTo(1L); + assertThat(query.getValue().size()).isEqualTo(20L); + verify(repository, never()).find(anyLong()); + verify(repository, never()).requirements(anyLong()); + verify(repository, never()).containers(anyLong()); + } + + @Test + void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() { + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + PageResult result = service.linkCandidates( + new LinkCandidateQuery(701L, "SEA-US", "45HC", null, null)); + + assertThat(result.records()).isEmpty(); + assertThat(result.total()).isZero(); + assertThat(result.pages()).isZero(); + verify(repository, times(1)).linkCandidates(any()); + assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName) + .doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode"); + } + @Test void releaseRejectsInvalidScheduleWithoutTransition() { OceanBookingEntity draft = completeBooking("DRAFT", 3); @@ -204,12 +259,12 @@ class OceanBookingServiceTest { } private static DraftData partialDraft(String remark) { - return new DraftData(null, null, null, null, null, null, null, null, null, + return new DraftData(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, remark, null, null); } private static DraftData completeDraft() { - return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", + return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", "MATSON", 81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶", "LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), null, @@ -224,6 +279,7 @@ class OceanBookingServiceTest { OceanBookingEntity value = booking(status, version); value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US"); value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L); + value.setCarrierCode("MATSON"); value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001"); value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID"); value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index 81158d28a..1efcea3a3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -10,9 +10,25 @@ class OutboundDispatchReadMapperContractTest { @Test void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception { String sql = String.join("\n", OutboundDispatchReadMapper.class - .getMethod("bindings", Long.class, Long.class, Long.class) + .getMethod("bindings", long.class) .getAnnotation(Select.class).value()); - assertThat(sql).contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo"); + assertThat(sql) + .contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo") + .contains("booking.id=#{containerBookingId}") + .doesNotContain("#{orderId}", "#{inboundId}"); + } + + @Test + void actionQueryLocksEveryRealInboundBindingForTheContainer() throws Exception { + String sql = String.join("\n", OutboundDispatchReadMapper.class + .getMethod("bindingsForUpdate", long.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("booking.id=#{containerBookingId}") + .contains("item.deleted=0", "inbound.deleted=0") + .contains("FOR UPDATE") + .doesNotContain("LIMIT 1"); } @Test @@ -31,18 +47,58 @@ class OutboundDispatchReadMapperContractTest { .getAnnotation(Select.class).value()[0]; assertThat(sql) - .contains("seal_no<>''") + .contains("evidence.seal_no<>''") .doesNotContain("<", ">"); } @Test - void oceanAndTruckQueriesUseTenantIsolationKey() throws Exception { - String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class, long.class) - .getAnnotation(Select.class).value()[0]; - String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class, long.class) + void evidenceGateAggregatesOnePhysicalEvidenceSetAcrossTheWholeLoadingPlan() throws Exception { + String sql = OutboundDispatchReadMapper.class.getMethod("evidence", long.class, long.class) .getAnnotation(Select.class).value()[0]; - assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}"); - assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}"); + assertThat(sql) + .contains("plan.id=#{loadingPlanId}", "plan.container_booking_id=#{containerBookingId}") + .contains("item.loading_plan_id=plan.id", "item.deleted=0") + .contains("evidence.inbound_id=item.inbound_id", "evidence.tenant_id=item.account_id") + .contains("COUNT(DISTINCT CASE", "evidence.evidence_type") + .doesNotContain("WHERE tenant_id=#{tenantId}", "inbound_id=#{inboundId}"); + } + + @Test + void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception { + String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class) + .getAnnotation(Select.class).value()[0]; + String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(oceanSql) + .contains("allocation.container_booking_id=#{containerBookingId}") + .contains("booking.customer_id customerId") + .doesNotContain("#{tenantId}"); + assertThat(truckSql) + .contains("container_booking_id=#{containerBookingId}", "loading_plan_id=#{loadingPlanId}") + .doesNotContain("#{tenantId}"); + } + + @Test + void timelineIncludesCustomsAndSailingHistoriesWithinTheFullOutboundScope() throws Exception { + String sql = String.join("\n", OutboundDispatchReadMapper.class + .getMethod("timeline", long.class, long.class, long.class, long.class, + long.class, Long.class, Long.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("SELECT 'CUSTOMS'", "FROM fl_customs_history h JOIN fl_customs_case customs_case") + .contains("customs_case.tenant_id=#{tenantId}", "customs_case.order_id=#{orderId}") + .contains("customs_case.inbound_id=#{inboundId}", + "customs_case.container_booking_id=#{containerBookingId}") + .contains("SELECT 'SAILING_SCHEDULE'", + "FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule") + .contains("port_schedule.customer_id=#{tenantId}", + "port_schedule.ocean_booking_id=#{oceanBookingId}") + .contains("port_schedule.container_booking_id=#{containerBookingId}") + .contains("schedule_inbound.tenant_id=#{tenantId}", "schedule_inbound.order_id=#{orderId}") + .contains("schedule_inbound.id=#{inboundId}") + .contains("ORDER BY occurredAt,module"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 1988f56f3..09c38aef4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -8,9 +8,13 @@ import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributo import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper; import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository; import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.LongStream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -18,120 +22,264 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class OutboundDispatchServiceTest { - private final OutboundDispatchRepository repository = mock(OutboundDispatchRepository.class); + private OutboundDispatchRepository repository; + + @BeforeEach + void setUp() { + repository = mock(OutboundDispatchRepository.class); + when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + } @Test - void gateFailsClosedWhenCustomsAndScheduleModulesAreUnavailable() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - stubReadyChain(); + void containerEvidenceFailureFailsEveryBindingConsistently() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); - GateResult result = service.checkGate(new Query(null, 11L, 22L)); + ContainerGateResult result = service.checkGate(22L); assertThat(result.passed()).isFalse(); - assertThat(result.items()).filteredOn(item -> !item.passed()) - .extracting(GateItem::code).containsExactlyInAnyOrder("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); + assertThat(result.inbounds()).hasSize(2); + assertThat(result.inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items()) + .filteredOn(item -> "LOADING_EVIDENCE_COMPLETE".equals(item.code())) + .extracting(GateItem::passed).containsExactly(false)); + verify(repository, times(1)).evidence(22L, 44L); } @Test - void aggregateUsesTenantKeyWhenCustomerEntityIdDiffers() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - stubReadyChain(); + void tenInboundBindingsShareOneCompleteEvidenceSetAndOneContainerCustomsResult() { + OutboundDispatchGateContributor customs = mock(OutboundDispatchGateContributor.class); + when(customs.code()).thenReturn("CUSTOMS_CLEARANCE"); + when(customs.assess(any())).thenReturn(new GateItem( + "CUSTOMS_CLEARANCE", "报关完成", true, "CLEARANCE_COMPLETED", "CLEARANCE_COMPLETED")); + OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null); + List tenBindings = LongStream.rangeClosed(11L, 20L) + .mapToObj(id -> binding(id, "I-" + id)).toList(); + stubContainer(tenBindings); - service.checkGate(new Query(null, 11L, 22L)); + ContainerGateResult result = service.checkGate(22L); - verify(repository).ocean(22L, 7L); - verify(repository).truck(22L, 44L, 7L); - verify(repository, never()).ocean(22L, 8L); - verify(repository, never()).truck(22L, 44L, 8L); + assertThat(result.passed()).isTrue(); + assertThat(result.inbounds()).hasSize(10).allSatisfy(value -> assertThat(value.gate().passed()).isTrue()); + verify(repository, times(1)).evidence(22L, 44L); + verify(customs, times(1)).assess(any()); } @Test - void confirmAdvancesDispatchWithoutMutatingLoadedInbound() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setOrderId(10L); state.setInboundId(11L); - state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-1"), anyString())).thenReturn(true); - when(repository.confirm(33L, 0, 9L, "admin", "ok")).thenReturn(1); - when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1")).thenReturn(1); - when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + void confirmAllowsMissingTruckAppointmentWhenEveryAuthoritativeGatePasses() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.truck(22L, 44L)).thenReturn(null); + stubConfirmMutation("no-truck"); - service.confirm(9L, "admin", "confirm-1", new ActionRequest(11L, 22L, 0, "ok")); + Detail result = service.confirm(22L, 9L, "admin", "no-truck", new ActionRequest(0, null)); - verify(repository).confirm(33L, 0, 9L, "admin", "ok"); - verify(repository).history(argThat(history -> "CONFIRM_DISPATCH".equals(history.getAction()) - && "WAITING_FOR_LOADING".equals(history.getToStatus()))); - verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1"); + assertThat(result.aggregate().truckAppointmentId()).isNull(); + assertThat(result.aggregate().gate().passed()).isTrue(); + verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull()); } @Test - void confirmNeverWritesStateWhenAnyRealGateFails() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "LOADING")); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-2"), anyString())).thenReturn(true); + void confirmAllowsPendingTruckAppointmentWhenEveryAuthoritativeGatePasses() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING")); + stubConfirmMutation("pending-truck"); - assertThatThrownBy(() -> service.confirm(9L, "admin", "confirm-2", - new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁"); + Detail result = service.confirm(22L, 9L, "admin", "pending-truck", new ActionRequest(0, null)); + + assertThat(result.aggregate().truckAppointmentStatus()).isEqualTo("PENDING"); + assertThat(result.aggregate().gate().passed()).isTrue(); + verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull()); + } + + @Test + void confirmAllowsMissingSailingScheduleContributor() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + stubConfirmMutation("no-schedule"); + + Detail result = service.confirm(22L, 9L, "admin", "no-schedule", new ActionRequest(0, null)); + + assertThat(result.aggregate().gate().passed()).isTrue(); + assertThat(result.aggregate().gate().inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items()) + .extracting(GateItem::code).doesNotContain("SAILING_SCHEDULE")); + } + + @Test + void detailReturnsOneVersionAndAllInboundBindings() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + OutboundDispatchEntity first = state(31L, 11L, "WAITING_FOR_LOADING", 3); + OutboundDispatchEntity second = state(32L, 12L, "WAITING_FOR_LOADING", 3); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(first); + when(repository.dispatch(7L, 12L, 22L)).thenReturn(second); + + Detail detail = service.detail(22L); + + assertThat(detail.aggregate().inbounds()).extracting(InboundBinding::inboundId).containsExactly(11L, 12L); + assertThat(detail.aggregate().dispatchStatus()).isEqualTo("WAITING_FOR_LOADING"); + assertThat(detail.aggregate().version()).isEqualTo(3); + } + + @Test + void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() { + CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); + OutboundDispatchService service = service(carrierSchedules); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + OutboundDispatchEntity first = state(31L, 11L, "READY_FOR_DISPATCH", 0); + OutboundDispatchEntity second = state(32L, 12L, "READY_FOR_DISPATCH", 0); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(first); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(second); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-container"), anyString())).thenReturn(true); + when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), eq("ok"))).thenReturn(1); + when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-container")).thenReturn(1); + + service.confirm(22L, 9L, "admin", "confirm-container", new ActionRequest(0, "ok")); + + verify(repository).lockBindings(22L); + verify(repository).confirm(31L, 0, 9L, "admin", "ok"); + verify(repository).confirm(32L, 0, 9L, "admin", "ok"); + verify(repository, times(1)).history(argThat(value -> "CONFIRM_DISPATCH".equals(value.getAction()) + && value.getContainerBookingId().equals(22L))); + verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); + } + + @Test + void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("blocked"), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "blocked", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("全部入库绑定"); + + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); + verify(repository, never()).history(any()); + } + + @Test + void differingTechnicalRowVersionsAreRejectedAsContainerConflict() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 1)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("mixed"), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "mixed", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("版本不一致"); verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - when(repository.bindings(any())).thenReturn(List.of(binding(), binding())); - assertThatThrownBy(() -> service.detail(new Query(10L, null, null))) - .isInstanceOf(BizException.class).hasMessageContaining("多个出仓聚合"); + void actualDepartedWarehouseFactBlocksContainerReverseBeforeAnyRowChanges() { + CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); + OutboundDispatchService service = service(carrierSchedules); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "WAITING_FOR_LOADING", 1)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "WAITING_FOR_LOADING", 1)); + when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true); + when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true); + + assertThatThrownBy(() -> service.reverse(22L, 9L, "admin", "reverse-blocked", + new ActionRequest(1, "误操作"))).isInstanceOf(BizException.class) + .hasMessageContaining("DEPARTED_WAREHOUSE"); + + verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void staleDispatchVersionIsRejectedWithoutPartialInboundAdvance() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L); - state.setStatus("READY_FOR_DISPATCH"); state.setVersion(2); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("stale"), anyString())).thenReturn(true); + void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + AtomicReference requestHash = new AtomicReference<>(); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("replay"), anyString())).thenAnswer(invocation -> { + requestHash.set(invocation.getArgument(3)); + return false; + }); + OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity(); + existing.setStatus("SUCCESS"); + when(repository.operation(9L, "CONFIRM_DISPATCH", "replay")).thenAnswer(invocation -> { + existing.setRequestHash(requestHash.get()); + return existing; + }); - assertThatThrownBy(() -> service.confirm(9L, "admin", "stale", - new ActionRequest(11L, 22L, 1, null))).isInstanceOf(BizException.class).hasMessageContaining("刷新"); + Detail replay = service.confirm(22L, 9L, "admin", "replay", new ActionRequest(0, null)); + + assertThat(replay.aggregate().containerBookingId()).isEqualTo(22L); + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void reusedIdempotencyKeyWithDifferentHashIsRejected() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); + void reusedIdempotencyKeyWithDifferentPayloadIsRejected() { + OutboundDispatchService service = serviceWithoutCarrier(); when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("reused"), anyString())).thenReturn(false); OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity(); - existing.setRequestHash("different"); existing.setStatus("SUCCESS"); + existing.setRequestHash("different"); + existing.setStatus("SUCCESS"); when(repository.operation(9L, "CONFIRM_DISPATCH", "reused")).thenReturn(existing); - assertThatThrownBy(() -> service.confirm(9L, "admin", "reused", - new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class) - .hasMessageContaining("用于不同请求"); + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "reused", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("用于不同请求"); } - private void stubReadyChain() { - when(repository.bindings(any())).thenReturn(List.of(binding())); + private OutboundDispatchService serviceWithoutCarrier() { + return service(null); + } + + private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) { + return new OutboundDispatchService(repository, + List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules); + } + + private void stubContainer() { + stubContainer(bindings()); + } + + private void stubContainer(List values) { + when(repository.bindings(22L)).thenReturn(values); when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED")); - when(repository.ocean(22L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED")); - when(repository.truck(22L, 44L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); - when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); + when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L)); + when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); } - private static OutboundDispatchReadMapper.Binding binding() { - return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A", + private void stubConfirmMutation(String key) { + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 0)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq(key), anyString())).thenReturn(true); + when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull())).thenReturn(1); + when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", key)).thenReturn(1); + } + + private static List bindings() { + return List.of(binding(11L, "I-11"), binding(12L, "I-12")); + } + + private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) { + return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A", "LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED"); } + private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) { + OutboundDispatchEntity value = new OutboundDispatchEntity(); + value.setId(id); + value.setTenantId(7L); + value.setOrderId(10L); + value.setInboundId(inboundId); + value.setContainerBookingId(22L); + value.setStatus(status); + value.setVersion(version); + return value; + } + private static OutboundDispatchGateContributor passing(String code) { return new OutboundDispatchGateContributor() { @Override public String code() { return code; } @@ -140,4 +288,5 @@ class OutboundDispatchServiceTest { } }; } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index 03a8422c9..0480e784e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -38,16 +38,25 @@ class OutboundDispatchSqlContractTest { } @Test - void openApiPublishesSelectorsGateAndBothVersionedActions() throws Exception { + void openApiPublishesOnlyContainerLevelGateDetailAndVersionedActions() throws Exception { String openApi = resource("openapi/phase3-outbound-dispatch-v1.yaml"); assertThat(openApi) .contains("outbound-dispatches:") - .contains("outbound-dispatches/detail:") - .contains("outbound-dispatches/check-gate:") - .contains("outbound-dispatches/confirm-dispatch:") - .contains("outbound-dispatches/reverse-dispatch:") + .contains("outbound-dispatches/{containerBookingId}:") + .contains("outbound-dispatches/{containerBookingId}/gate:") + .contains("outbound-dispatches/{containerBookingId}/confirm:") + .contains("outbound-dispatches/{containerBookingId}/reverse:") + .contains("every real inbound binding", "No partial success is possible") + .contains("Once an actual DEPARTED_WAREHOUSE node exists") + .contains("absence never blocks manual outbound confirmation") + .contains("PENDING or any non-terminal state never affects gate.passed") + .contains("Loading evidence is evaluated once for the physical container/loading plan") + .contains("CUSTOMS_DECLARATION whose completedAt must be non-null") + .contains("fl_customs_case is not an alternative gate fact") + .contains("missing standalone sailing schedule is reference-only") .contains("expectedVersion") - .contains("Idempotency-Key"); + .contains("Idempotency-Key") + .doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId"); } private static String migration() throws Exception { return resource("db/phase3-outbound-dispatch-schema.sql"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java new file mode 100644 index 000000000..83af6945e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java @@ -0,0 +1,65 @@ +package com.baiyue.firstleg.outbound.dispatch; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.firstleg.outbound.dispatch.controller.PlatformOutboundDispatchController; +import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformOutboundDispatchControllerContractTest { + @Test + void exposesOnlyContainerLevelResourcePathsAndExistingPermissions() { + assertThat(PlatformOutboundDispatchController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/v1/first-leg/outbound-dispatches"); + Map permissions = Map.of( + "list", "platform:firstleg:outbound-dispatch:list", + "detail", "platform:firstleg:outbound-dispatch:view", + "checkGate", "platform:firstleg:outbound-dispatch:check", + "confirm", "platform:firstleg:outbound-dispatch:confirm", + "reverse", "platform:firstleg:outbound-dispatch:reverse"); + permissions.forEach((name, permission) -> assertThat(method(name).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')")); + + assertThat(method("detail").getAnnotation(GetMapping.class).value()).containsExactly("/{containerBookingId}"); + assertThat(method("checkGate").getAnnotation(GetMapping.class).value()) + .containsExactly("/{containerBookingId}/gate"); + assertThat(method("confirm").getAnnotation(PostMapping.class).value()) + .containsExactly("/{containerBookingId}/confirm"); + assertThat(method("reverse").getAnnotation(PostMapping.class).value()) + .containsExactly("/{containerBookingId}/reverse"); + } + + @Test + void actionBodyContainsOnlyContainerVersionAndRemarkAndAuditsContainerRoot() { + assertThat(Arrays.stream(ActionRequest.class.getRecordComponents()).map(value -> value.getName())) + .containsExactly("expectedVersion", "remark") + .doesNotContain("inboundId", "containerBookingId"); + for (String name : List.of("confirm", "reverse")) { + BusinessAudit audit = method(name).getAnnotation(BusinessAudit.class); + assertThat(audit.resourceType()).isEqualTo("CONTAINER_BOOKING"); + assertThat(audit.resourceId()).isEqualTo("#containerBookingId"); + } + } + + @Test + void everyNonListOperationUsesOnlyContainerBookingIdPathTarget() { + for (String name : List.of("detail", "checkGate", "confirm", "reverse")) { + assertThat(Arrays.stream(method(name).getParameters()) + .filter(value -> value.isAnnotationPresent(PathVariable.class)) + .map(value -> value.getName())).containsExactly("containerBookingId"); + } + } + + private static Method method(String name) { + return Arrays.stream(PlatformOutboundDispatchController.class.getDeclaredMethods()) + .filter(value -> value.getName().equals(name)).findFirst().orElseThrow(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index 774d22da0..9c3fccd91 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -9,19 +9,26 @@ import static org.assertj.core.api.Assertions.assertThat; class CarrierScheduleQueryOpenApiContractTest { @Test - void publishesFrequencyFreshnessAndManualFallbackContract() throws Exception { + void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception { String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); assertThat(yaml) .contains("/api/platform/v1/carrier-schedule-queries:") - .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/attempts:") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:") + .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:") + .contains("x-permission: platform:firstleg:port-schedule-query:manual") .contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]") - .contains("MANUAL_REQUIRED") - .contains("coverageGap") - .contains("lastAttemptAt") - .contains("lastSuccessAt") - .contains("sourceUpdatedAt") - .contains("nextAllowedAt"); + .contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT") + .contains("DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED") + .contains("CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD") + .contains("enum: [READY, MANUAL_REQUIRED, STOPPED]") + .contains("latestActualNode:") + .contains("latestActualAt:") + .contains("manual actual node is equal to or earlier than latestActualNode") + .contains("strictly greater sequence than latestActualNode") + .contains("internal execution fields are intentionally absent") + .doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED") + .doesNotContain("/{queryId}/attempts:") + .doesNotContain("port-schedule-query:attempt"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index 173c7cbb1..b113eaaac 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -17,21 +17,23 @@ class PlatformCarrierScheduleQueryControllerContractTest { assertThat(PlatformCarrierScheduleQueryController.class.getAnnotation(RequestMapping.class).value()) .containsExactly("/api/platform/v1/carrier-schedule-queries"); Map permissions = Map.of( - "carriers", "platform:firstleg:port-schedule-query:view", + "carriers", "platform:firstleg:port-schedule-query:list", "page", "platform:firstleg:port-schedule-query:list", - "detail", "platform:firstleg:port-schedule-query:view", - "history", "platform:firstleg:port-schedule-query:view", + "detail", "platform:firstleg:port-schedule-query:list", + "history", "platform:firstleg:port-schedule-query:list", "create", "platform:firstleg:port-schedule-query:create", - "attempt", "platform:firstleg:port-schedule-query:attempt", - "manualResult", "platform:firstleg:port-schedule-query:manual"); + "manualResult", "platform:firstleg:port-schedule-query:manual", + "manualNode", "platform:firstleg:port-schedule-query:manual"); permissions.forEach((name, permission) -> { Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow(); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (name.equals("create") || name.equals("attempt") || name.equals("manualResult")) { + if (name.equals("create") || name.equals("manualResult") || name.equals("manualNode")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); + assertThat(Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods()) + .map(Method::getName)).doesNotContain("attempt"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java index 42959cc52..fb2edf48d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java @@ -18,8 +18,8 @@ class PlatformPortScheduleControllerContractTest { .containsExactly("/api/platform/v1/port-schedules"); Map permissions = Map.of( "page", "platform:firstleg:port-schedule:list", - "detail", "platform:firstleg:port-schedule:view", - "history", "platform:firstleg:port-schedule:view", + "detail", "platform:firstleg:port-schedule:list", + "history", "platform:firstleg:port-schedule:list", "create", "platform:firstleg:port-schedule:create", "update", "platform:firstleg:port-schedule:edit"); permissions.forEach((name, permission) -> { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtosTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtosTest.java new file mode 100644 index 000000000..df038e684 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtosTest.java @@ -0,0 +1,14 @@ +package com.baiyue.firstleg.portschedule.dto; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CarrierScheduleQueryDtosTest { + @Test + void omittedPageAndSizeBindToDocumentedDefaults() { + var query = new CarrierScheduleQueryDtos.Query(null, null, null, null, null, null, null, null, null); + assertThat(query.page()).isEqualTo(1L); + assertThat(query.size()).isEqualTo(20L); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index 6c6123073..1c8463923 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -1,5 +1,8 @@ package com.baiyue.firstleg.portschedule.repository; +import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; @@ -10,6 +13,26 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class CarrierScheduleQuerySqlContractTest { + @Test + void annotationSqlUsesExecutableComparisonOperators() throws Exception { + String selectDue = String.join("\n", CarrierScheduleQueryMapper.class + .getMethod("selectDue", java.time.LocalDateTime.class, int.class) + .getAnnotation(Select.class).value()); + String claimLease = String.join("\n", CarrierScheduleQueryMapper.class + .getMethod("claimLease", long.class, String.class, java.time.LocalDateTime.class, + java.time.LocalDateTime.class) + .getAnnotation(Update.class).value()); + + assertThat(selectDue) + .contains("next_allowed_at <= #{now}") + .contains("lease_until < #{now}") + .doesNotContain("<"); + assertThat(claimLease) + .contains("next_allowed_at <= #{now}") + .contains("lease_until < #{now}") + .doesNotContain("<"); + } + @Test void definesTaskAttemptsHistoryIdempotencyAndHardLimit() throws Exception { assertThat(moduleSql()) @@ -17,11 +40,34 @@ class CarrierScheduleQuerySqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation") + .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact") + .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact") + .contains("当前正常查询槽位已调用次数;最多3次") + .contains("下一正常槽位或槽内技术退避时间") .contains("attempt_count BETWEEN 0 AND 3") .contains("window_attempt_no BETWEEN 1 AND 3") .contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)") .contains("船司查询尝试不可修改") - .contains("船司查询历史不可删除"); + .contains("船司查询历史不可删除") + .contains("标准船节点事实不可修改") + .contains("船节点异常事实不可修改") + .contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')") + .contains("LOADED_ON_VESSEL") + .contains("ARRIVED_DESTINATION_PORT") + .contains("DISCHARGED_FROM_VESSEL") + .contains("PICKED_UP','EMPTY_RETURNED") + .contains("CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD") + .contains("CREATE PROCEDURE migrate_carrier_schedule_history_action_check()") + .contains("DROP CHECK chk_carrier_schedule_history_action") + .contains("'MANUAL_NODE','NODE_OBSERVED'") + .contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'") + .contains("provider_event_id") + .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") + .contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'") + .contains("'OFFICIAL_API','MANUAL'") + .contains("lease_owner") + .contains("consecutive_failures") + .doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'"); } @Test @@ -29,9 +75,10 @@ class CarrierScheduleQuerySqlContractTest { assertThat(moduleSql()) .contains("booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id") .contains("allocation.container_booking_id=NEW.container_booking_id") - .contains("5102184 AND 5102188") - .contains("platform:firstleg:port-schedule-query:attempt") + .contains("id IN (5102184,5102186,5102188)") .contains("platform:firstleg:port-schedule-query:manual") + .contains("permission.permission_code IN ('platform:firstleg:port-schedule-query:view'") + .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("船司查询任务角色授权未完整创建"); } @@ -43,9 +90,59 @@ class CarrierScheduleQuerySqlContractTest { assertThat(unified) .contains("fl_carrier_schedule_query") .contains("uk_carrier_schedule_query_scope") - .contains("5102184 AND 5102188") + .contains("id IN (5102184,5102186,5102188)") .contains("assert_carrier_schedule_query_schema") - .contains("船司查询任务角色授权未完整创建"); + .contains("CREATE PROCEDURE migrate_carrier_schedule_retry_columns()") + .contains("ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL'") + .contains("ADD COLUMN latest_standard_node VARCHAR(32) NULL") + .contains("ADD COLUMN latest_node_observed_at DATETIME(3) NULL") + .contains("ADD COLUMN stopped_at DATETIME(3) NULL") + .contains("ADD COLUMN stop_reason VARCHAR(500) NULL") + .contains("ADD COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0") + .contains("ADD COLUMN window_started_at DATETIME(3) NULL") + .contains("ADD COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0") + .contains("ADD COLUMN next_allowed_at DATETIME(3) NULL") + .contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)") + .contains("DROP CHECK chk_carrier_schedule_query_access") + .contains("DROP CHECK chk_carrier_schedule_query_carrier") + .contains("DROP CHECK chk_carrier_schedule_query_status") + .contains("DROP INDEX uk_carrier_schedule_query_scope") + .contains("ADD UNIQUE KEY uk_carrier_schedule_query_scope") + .contains("(customer_id,ocean_booking_id,container_scope_id)") + .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") + .contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'") + .contains("'OFFICIAL_API','MANUAL'") + .contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')") + .contains("ARRIVED_DESTINATION_PORT") + .contains("EMPTY_RETURNED") + .contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')") + .contains("SET @carrier_schedule_next = CASE") + .contains("CREATE PROCEDURE ensure_carrier_schedule_retry_check()") + .contains("CREATE PROCEDURE migrate_carrier_schedule_history_action_check()") + .contains("DROP CHECK chk_carrier_schedule_history_action") + .contains("'MANUAL_NODE','NODE_OBSERVED'") + .contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'") + .contains("ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)") + .contains("统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整") + .doesNotContain("DROP CHECK chk_carrier_schedule_query_attempt") + .doesNotContain("\n+--") + .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") + .contains("船司查询任务角色授权未完整创建") + .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'"); + } + + @Test + void unifiedOutboxSupportsVersionedCrossRegionCarrierNodeFacts() throws Exception { + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + assertThat(unified) + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key'") + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version'") + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence'") + .contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash'") + .contains("uk_inbound_outbox_event_key") + .contains("MODIFY COLUMN payload_hash VARCHAR(71)") + .contains("MODIFY COLUMN payload_json LONGTEXT NOT NULL"); } private static String moduleSql() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java index e1049bca6..e5b46e04c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java @@ -37,9 +37,11 @@ class PortScheduleSqlContractTest { @Test void reservesDedicatedPlatformPermissionsForBothOperationalRoles() throws Exception { assertThat(sql()) - .contains("5102180 AND 5102183") + .contains("id IN (5102180,5102182,5102183)") .contains("platform:firstleg:port-schedule:list") - .contains("platform:firstleg:port-schedule:view") + .contains("permission.permission_code='platform:firstleg:port-schedule:view'") + .contains("permission_code='platform:firstleg:port-schedule:view'") + .doesNotContain("'platform:firstleg:port-schedule:view','查看港务船期'") .contains("platform:firstleg:port-schedule:create") .contains("platform:firstleg:port-schedule:edit") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java index b7fc97fda..2778ca65e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java @@ -20,16 +20,18 @@ class CarrierScheduleAdapterRegistryTest { } @Test - void returnsStructuredManualRequirementsForCaptchaAndDynamicPage() { + void returnsStructuredManualRequirementsForCaptchaAndPendingOfficialApiCredentials() { var zim = registry.prepare("zim", "BILL_OF_LADING", "GAOU6898053"); var cosco = registry.prepare("COSCO", "CONTAINER_NO", "C123"); assertThat(zim.status()).isEqualTo("MANUAL_REQUIRED"); - assertThat(zim.accessMode()).isEqualTo("HCAPTCHA"); - assertThat(zim.coverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED"); - assertThat(zim.officialUrl()).contains("consnumber=GAOU6898053"); - assertThat(cosco.accessMode()).isEqualTo("DYNAMIC_IFRAME"); - assertThat(cosco.coverageGapCode()).isEqualTo("DYNAMIC_IFRAME_REQUIRED"); + assertThat(zim.accessMode()).isEqualTo("OFFICIAL_API"); + assertThat(zim.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED"); + assertThat(zim.officialUrl()).isEqualTo("https://api.zim.com/"); + assertThat(cosco.status()).isEqualTo("MANUAL_REQUIRED"); + assertThat(cosco.accessMode()).isEqualTo("OFFICIAL_API"); + assertThat(cosco.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED"); + assertThat(cosco.officialUrl()).isEqualTo("https://cop.lines.coscoshipping.com/copPortal/"); } @Test @@ -37,4 +39,16 @@ class CarrierScheduleAdapterRegistryTest { assertThatThrownBy(() -> registry.prepare("UNKNOWN", "BOOKING_NO", "X")) .hasMessageContaining("仅支持MATSON"); } + + @Test + void outboundUnknownCarrierFallsBackToManualWithoutPretendingToBeAnotherProvider() { + var result = registry.prepareOutbound("OTHER", "CONTAINER_NO", "CSNU1234567"); + + assertThat(result.carrierCode()).isEqualTo("OTHER"); + assertThat(result.accessMode()).isEqualTo("MANUAL"); + assertThat(result.status()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.coverageGapCode()).isEqualTo("UNSUPPORTED_CARRIER"); + assertThat(result.officialUrl()).isEmpty(); + assertThat(registry.prepareOutbound(null, "BOOKING_NO", "OB-1").carrierCode()).isEqualTo("OTHER"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java new file mode 100644 index 000000000..0f5bea2a6 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java @@ -0,0 +1,98 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class CarrierScheduleNodeOutboxServiceTest { + private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "scheduleZone", "Asia/Shanghai"); + } + + @Test + void writesVersionedActualPickupReadyEnvelopeWithStringIds() throws Exception { + CarrierScheduleQueryEntity query = query(); + CarrierScheduleNodeFactEntity fact = fact(); + + service.publish(query, fact, "ZCSU7881970"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(mapper).insert(captor.capture()); + InboundOutboxEntity row = captor.getValue(); + EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); + assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED); + assertThat(envelope.eventVersion()).isEqualTo(1); + assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING"); + assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003"); + assertThat(envelope.aggregateVersion()).isEqualTo(7); + assertThat(envelope.handoverVersion()).isEqualTo(8); + assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("PICKUP_READY"); + assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z"); + } + + @Test + void estimatedOnlyFactNeverEntersCrossRegionOutbox() { + CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null); + service.publish(query(), fact, "ZCSU7881970"); + verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); + } + + @Test + void initialOutboundDepartureUsesVersionZeroWithoutEnvelopeFailure() throws Exception { + CarrierScheduleQueryEntity query = query(); + query.setVersion(0); + CarrierScheduleNodeFactEntity fact = fact(); + fact.setStandardNode("DEPARTED_WAREHOUSE"); fact.setNodeSequence(10); + fact.setProviderCode("FIRSTLEG"); fact.setProviderEventId("OUTBOUND_CONFIRM"); + fact.setProviderNodeCode("OUTBOUND_CONFIRM"); + + service.publish(query, fact, "CSNU1234567"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(mapper).insert(captor.capture()); + EventEnvelope envelope = objectMapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(envelope.aggregateVersion()).isZero(); + assertThat(envelope.handoverVersion()).isEqualTo(1L); + assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("DEPARTED_WAREHOUSE"); + } + + private static CarrierScheduleQueryEntity query() { + CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); + value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L); + value.setContainerBookingId(2088000000000000003L); value.setOceanBookingId(2088000000000000004L); + value.setVersion(7); return value; + } + + private static CarrierScheduleNodeFactEntity fact() { + CarrierScheduleNodeFactEntity value = new CarrierScheduleNodeFactEntity(); + value.setId(2088000000000000005L); value.setStandardNode("PICKUP_READY"); value.setNodeSequence(80); + value.setObservedAt(LocalDateTime.of(2026, 8, 25, 9, 2)); + value.setActualAt(LocalDateTime.of(2026, 8, 25, 9, 0)); value.setProviderCode("ZIM"); + value.setProviderEventId("evt-1"); value.setProviderNodeCode("AVAILABLE"); + value.setSourceReference("ZIM:evt-1"); value.setRawPayloadHash("a".repeat(64)); return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java new file mode 100644 index 000000000..8607f987d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java @@ -0,0 +1,151 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; +import org.junit.jupiter.api.Test; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; + +class CarrierScheduleQuerySchedulerTest { + @Test + void declaresOneConfigurableDueScannerRatherThanDirectCarrierCronCalls() throws Exception { + Scheduled[] schedules = CarrierScheduleQueryScheduler.class.getMethod("poll").getAnnotationsByType(Scheduled.class); + assertThat(schedules).singleElement().satisfies(value -> { + assertThat(value.fixedDelayString()) + .isEqualTo("${baiyue.carrier-schedule.scheduler.scan-fixed-delay:PT1M}"); + assertThat(value.initialDelayString()) + .isEqualTo("${baiyue.carrier-schedule.scheduler.scan-initial-delay:PT10S}"); + assertThat(value.cron()).isEmpty(); + }); + } + + @Test + void drainsControlledBatchesAndStopsAtThePerTriggerSafetyLimit() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryScheduler scheduler = spy(new CarrierScheduleQueryScheduler(repository, + mock(CarrierScheduleQueryService.class), mock(CarrierScheduleIntegrationClient.class))); + CarrierScheduleQueryEntity first = task(1L); CarrierScheduleQueryEntity second = task(2L); + CarrierScheduleQueryEntity third = task(3L); + when(repository.due(any(), anyInt())).thenReturn(List.of(first, second), List.of(third)); + doNothing().when(scheduler).execute(any(), any()); + ReflectionTestUtils.setField(scheduler, "batchSize", 2); + ReflectionTestUtils.setField(scheduler, "maxTasksPerTrigger", 3); + ReflectionTestUtils.setField(scheduler, "scheduleZone", "Asia/Shanghai"); + ReflectionTestUtils.setField(scheduler, "clock", + Clock.fixed(Instant.parse("2026-08-21T01:00:00Z"), ZoneOffset.UTC)); + + scheduler.poll(); + + verify(repository).due(LocalDateTime.of(2026, 8, 21, 9, 0), 2); + verify(repository).due(LocalDateTime.of(2026, 8, 21, 9, 0), 1); + verify(scheduler, times(3)).execute(any(), any()); + } + + @Test + void keepsClaimingControlledBatchesUntilNoDueTasksRemain() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryScheduler scheduler = spy(new CarrierScheduleQueryScheduler(repository, + mock(CarrierScheduleQueryService.class), mock(CarrierScheduleIntegrationClient.class))); + when(repository.due(any(), anyInt())).thenReturn(List.of(task(1L), task(2L)), List.of()); + doNothing().when(scheduler).execute(any(), any()); + ReflectionTestUtils.setField(scheduler, "batchSize", 2); + ReflectionTestUtils.setField(scheduler, "maxTasksPerTrigger", 10); + ReflectionTestUtils.setField(scheduler, "scheduleZone", "Asia/Shanghai"); + ReflectionTestUtils.setField(scheduler, "clock", + Clock.fixed(Instant.parse("2026-08-21T04:30:00Z"), ZoneOffset.UTC)); + + scheduler.poll(); + + verify(repository, times(2)).due(LocalDateTime.of(2026, 8, 21, 12, 30), 2); + verify(scheduler, times(2)).execute(any(), any()); + } + + @Test + void leaseWinnerPersistsStandardNodesAndCompletesAttempt() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class); + CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class); + CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration); + CarrierScheduleQueryEntity task = new CarrierScheduleQueryEntity(); task.setId(1L); + task.setCarrierCode("COSCO"); task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("CSNU1234567"); + LocalDateTime now = LocalDateTime.of(2026, 8, 20, 12, 0); + when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true); + var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "DISCHARGED_FROM_VESSEL", + now, null, now, "USLAX", "Los Angeles", "provider:evt-1", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + var exception = new CarrierScheduleIntegrationClient.ExceptionObservation("hold-1", "CUSTOMS HOLD", + "CUSTOMS_HOLD", true, now, "USLAX", "Los Angeles", "provider:hold-1", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null, + new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation), List.of(exception), + null, null, now))); + + scheduler.execute(task, now); + + verify(service).applyProviderObservation(anyLong(), any()); + verify(service).applyProviderException(anyLong(), any()); + verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null); + } + + @Test + void leaseLoserNeverCallsProviderOrWritesAttempt() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class); + CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class); + CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration); + CarrierScheduleQueryEntity task = task(1L); + when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(false); + + scheduler.execute(task, LocalDateTime.of(2026, 8, 25, 9, 0)); + + verify(integration, never()).query(any(), any()); + verify(service, never()).completeScheduledAttempt(anyLong(), anyString(), any(), any()); + } + + @Test + void retryableProviderResultCannotAdvanceEvenWhenItCarriesStrayFacts() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class); + CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class); + CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration); + CarrierScheduleQueryEntity task = task(1L); task.setCarrierCode("ZIM"); + task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("ZCSU7881970"); + LocalDateTime now = LocalDateTime.of(2026, 8, 25, 9, 0); + var stray = new CarrierScheduleIntegrationClient.Observation("evt-stray", "AVAILABLE", "PICKUP_READY", + now, null, now, "USLAX", "Los Angeles", "ZIM:evt-stray", "a".repeat(64)); + when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true); + when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null, + new CarrierScheduleIntegrationClient.QueryResult("ZIM", "RETRYABLE_FAILURE", List.of(stray), List.of(), + "HTTP_503", "unavailable", now))); + + scheduler.execute(task, now); + + verify(service, never()).applyProviderObservation(anyLong(), any()); + verify(service, never()).applyProviderException(anyLong(), any()); + verify(service).completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable"); + } + + private static CarrierScheduleQueryEntity task(long id) { + CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 8c9801761..78b425d42 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -3,21 +3,27 @@ package com.baiyue.firstleg.portschedule.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; -import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.dao.DataIntegrityViolationException; import java.time.Clock; import java.time.Instant; @@ -30,26 +36,35 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; class CarrierScheduleQueryServiceTest { private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0); @Mock private CarrierScheduleQueryRepository repository; @Mock private PortScheduleRepository portSchedules; + @Mock private CarrierScheduleNodeOutboxService nodeOutbox; @InjectMocks private CarrierScheduleQueryService service; + private CarrierScheduleAdapterRegistry adapters; @BeforeEach void setUp() throws Exception { MockitoAnnotations.openMocks(this); set("objectMapper", new ObjectMapper().findAndRegisterModules()); - set("adapters", new CarrierScheduleAdapterRegistry()); + adapters = new CarrierScheduleAdapterRegistry(); + set("adapters", adapters); set("clock", Clock.fixed(Instant.parse("2026-08-10T04:00:00Z"), ZoneOffset.UTC)); when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1); - when(repository.attempts(anyLong())).thenReturn(List.of()); + when(repository.nodes(anyLong())).thenReturn(List.of()); + when(repository.exceptions(anyLong())).thenReturn(List.of()); when(repository.histories(anyLong())).thenReturn(List.of()); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("ZCSU7881970"); + when(portSchedules.containerBooking(20L)).thenReturn(container); } @Test @@ -62,42 +77,520 @@ class CarrierScheduleQueryServiceTest { var result = service.create(7L, "admin", "carrier-create", new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "BILL_OF_LADING", "GAOU6898053", null)); - assertThat(result.query().query().getCarrierCode()).isEqualTo("ZIM"); - assertThat(result.query().query().getStatus()).isEqualTo("READY"); + assertThat(result.query().query().carrierCode()).isEqualTo("ZIM"); + assertThat(result.query().query().status()).isEqualTo("READY"); assertThat(result.query().expired()).isTrue(); - verify(repository).insert(any(CarrierScheduleQueryEntity.class)); + ArgumentCaptor created = ArgumentCaptor.forClass(CarrierScheduleQueryEntity.class); + verify(repository).insert(created.capture()); + assertThat(created.getValue().getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); } @Test - void recordsManualRequiredAttemptWithoutBypassingCaptcha() { + void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("中远海运"); booking.setCarrierCode("COSCO"); booking.setBookingNo("OB-260810-0001"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + + assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); + assertThat(result.getCarrierCode()).isEqualTo("COSCO"); + assertThat(result.getStatus()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.getAccessMode()).isEqualTo("OFFICIAL_API"); + assertThat(result.getManualReason()).contains("Cargo Tracking API"); + assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); + assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); + assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); + } + + @Test + void outboundUnknownCarrierCreatesManualTaskAndPreservesBookingCarrierIdentity() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyId(9988L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + + assertThat(result.getCarrierCode()).isEqualTo("OTHER"); + assertThat(result.getAccessMode()).isEqualTo("MANUAL"); + assertThat(result.getStatus()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.getManualReason()).contains("carrierCode=OTHER", "shippingCompanyId=9988", "shippingCompanyName=示例船公司"); + assertThat(result.getNextAllowedAt()).isNull(); + assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); + } + + @Test + void outboundSupportedCarrierCreatesReadyTask() { + ReflectionTestUtils.setField(adapters, "matsonAutomated", true); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("美森"); booking.setCarrierCode("MATSON"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("MATU4646247"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + + assertThat(result.getCarrierCode()).isEqualTo("MATSON"); + assertThat(result.getStatus()).isEqualTo("READY"); + assertThat(result.getAccessMode()).isEqualTo("PUBLIC_WEB_PROXY"); + assertThat(result.getManualReason()).isNull(); + assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + } + + @Test + void outboundReplayKeepsSingleTaskAndSingleDepartureFact() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + + CarrierScheduleQueryEntity first = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(first); + CarrierScheduleQueryEntity replay = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + + assertThat(replay).isSameAs(first); + verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class)); + verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + } + + @Test + void outboundPersistenceContractMismatchReturnsExplicitBusinessConflict() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + org.mockito.Mockito.doThrow(new DataIntegrityViolationException("check constraint rejected")) + .when(repository).insert(any(CarrierScheduleQueryEntity.class)); + + assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin")) + .isInstanceOf(BizException.class) + .hasMessageContaining("船节点持久化契约与当前服务不一致") + .hasMessageContaining("QUERY_TASK") + .hasMessageContaining("OTHER/MANUAL"); + verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + } + + @Test + void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() { CarrierScheduleQueryEntity task = task(0, 0); + task.setLatestStandardNode("IN_TRANSIT"); when(repository.lock(1L)).thenReturn(task); + when(repository.find(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); - when(repository.find(1L)).thenAnswer(invocation -> task); - var result = service.attempt(1L, 7L, "admin", "carrier-attempt", - new AttemptRequest(0, "官网查询")); + var result = service.applyProviderObservation(1L, new ProviderObservationRequest("TRACK_API", "evt-1", + "DISCHARGED", "ARRIVED_DESTINATION_PORT", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + + assertThat(result.query().query().status()).isEqualTo("READY"); + assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT"); + assertThat(result.nodes()).isEmpty(); + } + + @Test + void estimatedObservationIsSavedWithoutAdvancingActualNode() { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setLatestStandardNode("LOADED_ON_VESSEL"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + + service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "eta-1", "PRE_ARRIVAL", + "ARRIVED_DESTINATION_PORT", NOW, NOW.plusDays(10), null, null, null, "HEDE:X", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + + assertThat(task.getLatestStandardNode()).isEqualTo("LOADED_ON_VESSEL"); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + } + + @Test + void lateActualFactIsSavedButCannotRegressLatestNode() { + CarrierScheduleQueryEntity task = task(0, 5); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + + service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "late-1", "DISCHARGED", + "DISCHARGED_FROM_VESSEL", NOW, null, NOW.minusDays(2), null, null, "HEDE:X", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); + + assertThat(task.getLatestStandardNode()).isEqualTo("PICKED_UP"); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + } + + @Test + void onlyActualEmptyReturnedStopsTask() { + CarrierScheduleQueryEntity task = task(0, 2); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "return-1", "EMPTY_RETURNED", + "EMPTY_RETURNED", NOW, null, NOW, null, null, "HEDE:X", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")); + + assertThat(task.getStatus()).isEqualTo("STOPPED"); + assertThat(task.getStopReason()).isEqualTo("已还柜"); + assertThat(task.getNextAllowedAt()).isNull(); + } + + @Test + void lateActualFactAfterEmptyReturnIsAuditedWithoutRestartingTask() { + CarrierScheduleQueryEntity task = task(0, 4); + task.setStatus("STOPPED"); task.setLatestStandardNode("EMPTY_RETURNED"); + task.setLatestNodeObservedAt(NOW); task.setNextAllowedAt(null); task.setStopReason("已还柜"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); + + service.applyProviderObservation(1L, new ProviderObservationRequest("ZIM", "late-discharge", + "DISCHARGED", "DISCHARGED_FROM_VESSEL", NOW.plusHours(1), null, NOW.minusDays(2), + "USLAX", "Los Angeles", "ZIM:late-discharge", "d".repeat(64))); + + assertThat(task.getStatus()).isEqualTo("STOPPED"); + assertThat(task.getLatestStandardNode()).isEqualTo("EMPTY_RETURNED"); + assertThat(task.getNextAllowedAt()).isNull(); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970")); + } + + @Test + void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() { + CarrierScheduleQueryEntity task = task(0, 3); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + var result = service.addManualNode(1L, 7L, "admin", "manual-empty-1", + new ManualNodeRequest(3, "EMPTY_RETURNED", NOW, null, NOW, null, "Los Angeles", "人工确认还柜")); + + assertThat(result.query().query().status()).isEqualTo("STOPPED"); + assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED"); + verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); + } + + @Test + void manualActualRejectsSameAndEarlierNodesIncludingSameKeyRetryWithoutDrift() { + CarrierScheduleQueryEntity task = task(0, 3); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + task.setLatestNodeObservedAt(NOW.minusDays(1)); + when(repository.lock(1L)).thenReturn(task); + + ManualNodeRequest sameNode = new ManualNodeRequest(3, "PICKED_UP", NOW, null, + NOW.plusDays(1), null, "Los Angeles", "重复确认提柜"); + assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode)) + .isInstanceOf(BizException.class) + .hasMessage("人工实际节点必须严格晚于当前最新实际节点"); + assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode)) + .isInstanceOf(BizException.class) + .hasMessage("人工实际节点必须严格晚于当前最新实际节点"); + assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-pickup-ready-late", + new ManualNodeRequest(3, "PICKUP_READY", NOW, null, NOW.plusDays(2), null, + "Los Angeles", "倒序补录可提柜"))) + .isInstanceOf(BizException.class) + .hasMessage("人工实际节点必须严格晚于当前最新实际节点"); + + verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + verify(repository, never()).insert(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity.class)); + verify(repository, never()).completeOperation(anyLong(), anyLong(), anyString(), anyString()); + verify(nodeOutbox, never()).publish(any(), any(), anyString()); + } + + @Test + void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() { + CarrierScheduleQueryEntity before = task(0, 3); + before.setStatus("READY"); before.setLatestStandardNode("LOADED_ON_VESSEL"); + before.setLatestNodeObservedAt(NOW.minusDays(1)); + CarrierScheduleQueryEntity after = task(0, 4); + after.setStatus("READY"); after.setLatestStandardNode("LOADED_ON_VESSEL"); + after.setLatestNodeObservedAt(NOW.minusDays(1)); + when(repository.lock(1L)).thenReturn(before); when(repository.find(1L)).thenReturn(after); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + var result = service.addManualNode(1L, 7L, "admin", "manual-estimated-1", + new ManualNodeRequest(3, "ARRIVED_DESTINATION_PORT", NOW, NOW.plusDays(5), null, + "USLAX", "Los Angeles", "人工预计到港")); + + assertThat(result.query().query().version()).isEqualTo(4); + assertThat(result.query().query().latestActualNode()).isEqualTo("LOADED_ON_VESSEL"); + assertThat(result.query().query().latestActualAt()).isEqualTo(NOW.minusDays(1)); + verify(repository).updateState(before, 3, 7L, "admin"); + } + + @Test + void holdIsPersistedAsSeparateImmutableExceptionFact() { + CarrierScheduleQueryEntity task = task(0, 1); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true); + + service.applyProviderException(1L, new ProviderExceptionRequest("MATSON_PUBLIC_TRACKING", "hold-1", + "CONTAINER ON HOLD - Customs Clearance", "CUSTOMS_HOLD", true, NOW, "USLAX", + "Long Beach", "MATSON:9974749", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd")); + + ArgumentCaptor fact = + ArgumentCaptor.forClass(CarrierScheduleExceptionFactEntity.class); + verify(repository).insertIfAbsent(fact.capture()); + assertThat(fact.getValue().getExceptionCode()).isEqualTo("CUSTOMS_HOLD"); + assertThat(fact.getValue().getActive()).isTrue(); + assertThat(task.getLatestStandardNode()).isNull(); + } + + @Test + void allFourHoldTypesAndReleaseFactsRemainSeparateFromBusinessProgress() { + CarrierScheduleQueryEntity task = task(0, 1); + task.setLatestStandardNode("IN_TRANSIT"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true); + + for (String code : List.of("CUSTOMS_HOLD", "TERMINAL_HOLD", "DOCUMENT_HOLD", "UNKNOWN_HOLD")) { + service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-ON", + code, code, true, NOW, null, null, "provider:" + code, "a".repeat(64))); + service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-OFF", + code + " RELEASED", code, false, NOW.plusMinutes(1), null, null, + "provider:" + code + ":released", "b".repeat(64))); + } + + verify(repository, times(8)).insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class)); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + assertThat(task.getLatestStandardNode()).isEqualTo("IN_TRANSIT"); + } + + @Test + void providerEventIdReplayRequiresTheSameRawPayloadHash() { + CarrierScheduleQueryEntity task = task(0, 1); + task.setLatestStandardNode("IN_TRANSIT"); + CarrierScheduleNodeFactEntity existing = new CarrierScheduleNodeFactEntity(); + existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64)); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false); + when(repository.node(1L, "ZIM:evt-duplicate")).thenReturn(existing); + ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN", + "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64)); + + service.applyProviderObservation(1L, replay); + + verify(nodeOutbox, never()).publish(any(), any(), anyString()); + ProviderObservationRequest conflict = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN", + "AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "b".repeat(64)); + assertThatThrownBy(() -> service.applyProviderObservation(1L, conflict)) + .isInstanceOf(BizException.class).hasMessageContaining("不同rawPayloadHash"); + } + + @Test + void everySchedulerCompletionWritesImmutableAttempt() { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + service.completeScheduledAttempt(1L, "NO_CHANGE", null, null); - assertThat(result.query().query().getStatus()).isEqualTo("MANUAL_REQUIRED"); - assertThat(result.query().query().getAttemptCount()).isEqualTo(1); - assertThat(result.query().query().getManualReason()).contains("hCaptcha"); ArgumentCaptor attempt = ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class); verify(repository).insert(attempt.capture()); - assertThat(attempt.getValue().getCoverageGap()).isTrue(); - assertThat(attempt.getValue().getCoverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED"); + assertThat(attempt.getValue().getAttemptStatus()).isEqualTo("NO_CHANGE"); + assertThat(attempt.getValue().getOperatorName()).isEqualTo("carrier-scheduler"); + assertThat(attempt.getValue().getWindowAttemptNo()).isEqualTo(1); + assertThat(task.getStatus()).isEqualTo("READY"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); } @Test - void blocksFourthAttemptInsideSameThreeDayWindow() { - CarrierScheduleQueryEntity task = task(3, 4); + void everySchedulerOutcomeUsesTheNextFixedSlotWithoutExtraRetryTimes() { + List outcomes = List.of("SUCCESS", "NO_CHANGE", "MANUAL_REQUIRED", "RETRYABLE_FAILURE", + "PERMANENT_FAILURE"); + List tasks = outcomes.stream().map(value -> task(0, 0)).toList(); + when(repository.lock(1L)).thenReturn(tasks.get(0), tasks.get(1), tasks.get(2), tasks.get(3), tasks.get(4)); + when(repository.find(1L)).thenReturn(tasks.get(0), tasks.get(1), tasks.get(2), tasks.get(3), tasks.get(4)); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + outcomes.forEach(outcome -> service.completeScheduledAttempt(1L, outcome, null, null)); + + assertThat(tasks.get(0).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(1).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(2).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(3).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 15)); + assertThat(tasks.get(4).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(3).getAttemptCount()).isEqualTo(1); + assertThat(tasks).filteredOn(value -> value != tasks.get(3)) + .allSatisfy(value -> assertThat(value.getAttemptCount()).isZero()); + assertThat(tasks.get(0).getStatus()).isEqualTo("READY"); + assertThat(tasks.get(1).getStatus()).isEqualTo("READY"); + assertThat(tasks.get(2).getStatus()).isEqualTo("MANUAL_REQUIRED"); + assertThat(tasks.get(3).getStatus()).isEqualTo("READY"); + assertThat(tasks.get(4).getStatus()).isEqualTo("MANUAL_REQUIRED"); + } + + @Test + void retryableFailureUsesFifteenThenThirtyMinutesAndStopsAfterThreeCallsInTheSlot() throws Exception { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + set("clock", Clock.fixed(Instant.parse("2026-08-10T01:00:00Z"), ZoneOffset.UTC)); + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 9, 15)); + assertThat(task.getAttemptCount()).isEqualTo(1); + + set("clock", Clock.fixed(Instant.parse("2026-08-10T01:15:00Z"), ZoneOffset.UTC)); + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 9, 45)); + assertThat(task.getAttemptCount()).isEqualTo(2); + + set("clock", Clock.fixed(Instant.parse("2026-08-10T01:45:00Z"), ZoneOffset.UTC)); + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(task.getAttemptCount()).isZero(); + + ArgumentCaptor attempts = + ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class); + verify(repository, org.mockito.Mockito.times(3)).insert(attempts.capture()); + assertThat(attempts.getAllValues()).extracting(CarrierScheduleQueryAttemptEntity::getWindowAttemptNo) + .containsExactly(1, 2, 3); + } + + @Test + void retryThatWouldReachTheNextSlotWaitsForTheSlotInstead() throws Exception { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 12, 20)); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + set("clock", Clock.fixed(Instant.parse("2026-08-10T04:20:00Z"), ZoneOffset.UTC)); + + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable"); + + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(task.getAttemptCount()).isZero(); + } + + @Test + void missedNormalSlotIsDeferredWithoutCallingTheProviderOrWritingAnAttempt() { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + task.setLeaseOwner("worker"); task.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 10, 5)); when(repository.lock(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); - assertThatThrownBy(() -> service.attempt(1L, 7L, "admin", "carrier-fourth", - new AttemptRequest(4, null))) - .isInstanceOf(BizException.class).hasMessageContaining("最多查询3次"); + boolean deferred = service.deferMissedNormalSlot(1L, LocalDateTime.of(2026, 8, 10, 10, 0)); - verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + assertThat(deferred).isTrue(); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(task.getLeaseOwner()).isNull(); assertThat(task.getLeaseUntil()).isNull(); + verify(repository, never()).insert(any(CarrierScheduleQueryAttemptEntity.class)); + } + + @Test + void staleRetryBecomesANewFirstAttemptOnlyDuringTheNextFixedSlotMinute() { + CarrierScheduleQueryEntity atSlot = task(2, 0); + atSlot.setStatus("READY"); atSlot.setWindowStartedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + atSlot.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 45)); atSlot.setConsecutiveFailures(2); + atSlot.setLeaseOwner("worker"); atSlot.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 12, 35)); + when(repository.lock(1L)).thenReturn(atSlot); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + boolean executeCurrentSlot = service.deferMissedNormalSlot(1L, + LocalDateTime.of(2026, 8, 10, 12, 30, 20)); + + assertThat(executeCurrentSlot).isFalse(); + assertThat(atSlot.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(atSlot.getAttemptCount()).isZero(); + assertThat(atSlot.getLeaseOwner()).isEqualTo("worker"); + + CarrierScheduleQueryEntity outsideSlot = task(2, 0); + outsideSlot.setStatus("READY"); outsideSlot.setWindowStartedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + outsideSlot.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 45)); + outsideSlot.setLeaseOwner("worker"); outsideSlot.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 13, 5)); + when(repository.lock(2L)).thenReturn(outsideSlot); + + boolean deferred = service.deferMissedNormalSlot(2L, LocalDateTime.of(2026, 8, 10, 13, 0)); + + assertThat(deferred).isTrue(); + assertThat(outsideSlot.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 16, 0)); + assertThat(outsideSlot.getAttemptCount()).isZero(); + assertThat(outsideSlot.getLeaseOwner()).isNull(); + } + + @Test + void matsonIsAdvertisedAsAutomatedOnlyWhenDeploymentEnablesIt() { + CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry(); + assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isFalse(); + assertThat(value.coverageGap()).isTrue(); + }); + + ReflectionTestUtils.setField(registry, "matsonAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_WEB_PROXY"); + }); + + ReflectionTestUtils.setField(registry, "hedeAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "HEDE".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_HTML"); + }); + + ReflectionTestUtils.setField(registry, "smlAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "SML".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM"); + }); + + ReflectionTestUtils.setField(registry, "zimOfficialApiEnabled", true); + + assertThat(registry.descriptors()).filteredOn(value -> "ZIM".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("OFFICIAL_API"); + }); + + ReflectionTestUtils.setField(registry, "emcAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "EMC".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM"); + }); } @Test @@ -121,7 +614,7 @@ class CarrierScheduleQueryServiceTest { value.setId(1L); value.setQueryNo("CSQ1"); value.setCustomerId(10001L); value.setOceanBookingId(10L); value.setContainerBookingId(20L); value.setCarrierCode("ZIM"); value.setTrackingType("BILL_OF_LADING"); value.setTrackingNo("GAOU6898053"); - value.setOfficialUrl("https://www.zimchina.com/tools/track-a-shipment"); value.setAccessMode("HCAPTCHA"); + value.setOfficialUrl("https://api.zim.com/"); value.setAccessMode("OFFICIAL_API"); value.setStatus("MANUAL_REQUIRED"); value.setWindowStartedAt(NOW.minusDays(1)); value.setAttemptCount(attempts); value.setNextAllowedAt(NOW); value.setVersion(version); return value; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java new file mode 100644 index 000000000..0292a766a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java @@ -0,0 +1,28 @@ +package com.baiyue.firstleg.portschedule.service; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class CarrierScheduleQuerySlotsTest { + @Test + void returnsTheFirstStrictlyLaterShanghaiSlotAtExactBoundaries() { + assertThat(CarrierScheduleQuerySlots.nextSlot(at(8, 59, 59))).isEqualTo(at(9, 0, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(9, 0, 0))).isEqualTo(at(12, 30, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(12, 29, 59))).isEqualTo(at(12, 30, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(12, 30, 0))).isEqualTo(at(16, 0, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(15, 59, 59))).isEqualTo(at(16, 0, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(16, 0, 0))) + .isEqualTo(LocalDateTime.of(2026, 8, 22, 9, 0)); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(9, 0, 59))).isFalse(); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(9, 1, 0))).isTrue(); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(12, 30, 30))).isFalse(); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 15, 0), 1, at(10, 0, 0))).isFalse(); + } + + private static LocalDateTime at(int hour, int minute, int second) { + return LocalDateTime.of(2026, 8, 21, hour, minute, second); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java new file mode 100644 index 000000000..3f9734d86 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java @@ -0,0 +1,67 @@ +package com.baiyue.firstleg.pricebookquery; + +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class PriceBookQueryArchitectureTest { + private static final String FIRSTLEG = "com.baiyue.firstleg.."; + private static final String SLICE = "com.baiyue.firstleg.pricebookquery.."; + private static final String APPLICATION = "com.baiyue.firstleg.pricebookquery.application.."; + private static final String INPUT_PORT = "com.baiyue.firstleg.pricebookquery.application.port.in.."; + private static final String OUTPUT_PORT = "com.baiyue.firstleg.pricebookquery.application.port.out.."; + private static final String MODEL = "com.baiyue.firstleg.pricebookquery.application.model.."; + private static final String PERSISTENCE = "com.baiyue.firstleg.pricebookquery.adapter.out.persistence.."; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.baiyue.firstleg"); + + @Test + void applicationStaysFreeOfWebAndPersistenceFrameworks() { + noClasses().that().resideInAPackage(APPLICATION) + .should().dependOnClassesThat().resideInAnyPackage( + "org.springframework.web..", + "org.apache.ibatis..", + "com.baomidou.mybatisplus..") + .check(PRODUCTION_CLASSES); + } + + @Test + void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() { + classes().that().resideInAPackage(APPLICATION) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION))) + .check(PRODUCTION_CLASSES); + } + + @Test + void persistenceAdapterCollaboratesThroughTheOutputPortAndModel() { + classes().that().resideInAPackage(PERSISTENCE) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(SLICE) + .or(resideInAPackage(PERSISTENCE)) + .or(resideInAPackage(OUTPUT_PORT)) + .or(resideInAPackage(MODEL))) + .andShould().beAssignableTo(PriceBookQueryReader.class) + .check(PRODUCTION_CLASSES); + } + + @Test + void consumersOnlySeeTheInputPortAndReadModel() { + noClasses().that().resideOutsideOfPackage(SLICE) + .should().dependOnClassesThat( + resideInAPackage(SLICE) + .and(resideOutsideOfPackage(INPUT_PORT)) + .and(resideOutsideOfPackage(MODEL))) + .check(PRODUCTION_CLASSES); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java new file mode 100644 index 000000000..db339e20a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.pricebookquery; + +import com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PriceBookQueryServiceTest { + @Mock private PriceBookQueryReader reader; + @InjectMocks private PriceBookQueryService service; + + @Test + void delegatesStableReadOperationsToTheOutputPort() { + ChannelPrefixRow prefix = new ChannelPrefixRow("SEA", "IN", "OUT"); + when(reader.findActiveVersionId()).thenReturn(9L); + when(reader.findChannelPrefix("SEA")).thenReturn(prefix); + + assertThat(service.activeVersionId()).isEqualTo(9L); + assertThat(service.channelPrefix("SEA")).isSameAs(prefix); + verify(reader).findActiveVersionId(); + verify(reader).findChannelPrefix("SEA"); + } + + @Test + void keepsChannelAvailabilityAsApplicationLogic() { + ChannelRow channel = new ChannelRow(); + channel.setChannelCode("SEA"); + when(reader.findChannels(9L, 3L)).thenReturn(List.of(channel)); + + assertThat(service.channelAvailable(9L, 3L, "SEA")).isTrue(); + assertThat(service.channelAvailable(9L, 3L, "AIR")).isFalse(); + verify(reader, org.mockito.Mockito.times(2)).findChannels(9L, 3L); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java index c1b94e43d..7fdcef6cd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java index 06fe39203..6c9af0572 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index 68a9ec0f3..16944ca9c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -53,15 +53,6 @@ class ContainerBookingMapperContractTest { .contains("customer_item.account_id = #{query.customerId}"); } - @Test - @DisplayName("首次关联客户隔离查询使用原生不等号") - void customerMismatchQueryUsesNativeSqlOperator() throws Exception { - String sql = ContainerBookingMapper.class - .getDeclaredMethod("hasMismatchedCustomerItems", long.class, long.class) - .getAnnotation(Select.class).value()[0]; - assertThat(sql).contains("i.account_id<>#{customerId}").doesNotContain("<>"); - } - @Test @DisplayName("关联订舱 SQL 的预计到港参数与 Mapper 参数名一致") void linkReleasedBookingUsesDeclaredEstimatedArrivalParameter() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index afecb8b9e..b19652ad9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -12,6 +12,14 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class ContainerOverviewSqlContractTest { + @Test void overviewIncludesExactlyTheFourCurrentBusinessDocumentTypes() throws Exception { + String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("'DESTINATION_CLEARANCE'", "'CUSTOMS_DECLARATION'", "'DELIVERY_ORDER'", "'PICKUP_ORDER'") + .contains("ORDER BY document_type"); + } + @Test void pdaSummarySelectsRecordFieldsExplicitlyIncludingTaskVersion() throws Exception { String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("pickTask", long.class) .getAnnotation(Select.class).value()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java new file mode 100644 index 000000000..ca27c12fb --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class FulfillmentTypeSqlContractTest { + @Test + void baselinesAndUnifiedMigrationPersistIndependentFulfillmentType() throws Exception { + String order = Files.readString(resolve("order/src/main/resources/db/order.sql"), StandardCharsets.UTF_8); + String inbound = Files.readString(resolve("firstleg/src/main/resources/db/phase2-inbound-schema.sql"), StandardCharsets.UTF_8); + String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + + assertFulfillmentMigration(order, "baiyue_order.ord_order"); + assertFulfillmentMigration(inbound, "fl_inbound_order"); + assertFulfillmentMigration(unified, "baiyue_order.ord_order"); + assertFulfillmentMigration(unified, "fl_inbound_order"); + assertThat(unified) + .contains("unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type')") + .contains("unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type')"); + } + + private static void assertFulfillmentMigration(String sql, String table) { + assertThat(sql) + .contains("fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'") + .contains("ADD COLUMN fulfillment_type VARCHAR(32) NULL") + .contains("SET fulfillment_type = 'STANDARD'") + .contains("MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'") + .contains(table); + } + + private static Path resolve(String path) { + Path direct = Path.of(path); + return Files.exists(direct) ? direct : Path.of("..").resolve(path).normalize(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java new file mode 100644 index 000000000..4ee884bc3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoadingCompletedOutboxRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void claimRechecksRetryWindowAfterCandidateSelection() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundOutboxEntity.class); + InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); + LoadingCompletedOutboxRepository repository = new LoadingCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + when(mapper.update(isNull(InboundOutboxEntity.class), wrapper.capture())).thenReturn(1); + LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); + + assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + + assertThat(wrapper.getValue().getSqlSegment()) + .contains("next_retry_at", "<=", "publish_status", "locked_at"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index 11e77ed09..d25d76a32 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -116,6 +116,21 @@ class LoadingListQueryMapperContractTest { .contains("operator_id"); } + @Test + void deliveryAndPickupUseTheCanonicalDocumentInsertAndPlanHistoryMapper() throws Exception { + String documentInsert = sql("insertDocument", Insert.class); + String historyInsert = sql("insertHistory", Insert.class); + assertThat(documentInsert) + .contains("INSERT INTO fl_container_document") + .contains("#{documentType}", "#{fileId}", "#{fileHash}") + .contains("CURRENT_TIMESTAMP(3)", "version") + .doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); + assertThat(historyInsert) + .contains("INSERT INTO fl_loading_plan_history") + .contains("#{planId}", "#{operationId}", "#{actionType}") + .contains("#{beforeSnapshot}", "#{afterSnapshot}"); + } + private static String sql(String methodName, Class annotationType) throws Exception { Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index 47261f748..6a91c556b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -29,6 +29,31 @@ class LoadingPickItemMapperContractTest { assertThat(progress.getUnresolvedCount()).isEqualTo(1); } + @Test + void inboundProgressUsesSettledTerminalQuantitiesAndRejectsNonTerminalItems() throws Exception { + Method method = LoadingPickItemMapper.class.getDeclaredMethod( + "selectInboundProgress", long.class, long.class); + String sql = method.getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0") + .contains("WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0") + .contains("AND pick_item.picked_package_count<=plan_item.package_count") + .contains("THEN pick_item.picked_package_count") + .contains("ELSE plan_item.package_count") + .contains("WHEN pick_item.id IS NULL THEN 1") + .contains("WHEN pick_item.item_status='PICKED'") + .contains("AND pick_item.picked_package_count=plan_item.package_count THEN 0") + .contains("ELSE 1") + .doesNotContain("pick_item.picked_package_count<>plan_item.package_count"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.item_status='PARTIAL'")).isEqualTo(2); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.item_status='SKIPPED'")).isEqualTo(2); + assertThat(sql).doesNotContain("pick_item.item_status='PENDING' THEN 0") + .doesNotContain("pick_item.item_status='PICKING' THEN 0"); + } + @Test void completionCheckCoversAllActivePlanItems() throws Exception { Method method = LoadingPickItemMapper.class.getDeclaredMethod("unfinishedCount", long.class); @@ -39,7 +64,17 @@ class LoadingPickItemMapperContractTest { .contains("plan_item.deleted = 0") .contains("pick_task.loading_plan_id = #{planId}") .contains("pick_task.task_status = 'LOADING'") - .contains("pick_item.picked_package_count = plan_item.package_count"); + .contains("pick_item.item_status = 'PICKED'") + .contains("pick_item.picked_package_count = plan_item.package_count") + .contains("pick_item.item_status = 'PARTIAL'") + .contains("pick_item.picked_package_count > 0") + .doesNotContain("pick_item.picked_package_count < plan_item.package_count") + .contains("pick_item.item_status = 'SKIPPED'") + .contains("pick_item.picked_package_count = 0") + .doesNotContain("pick_item.item_status = 'PENDING'") + .doesNotContain("pick_item.item_status = 'PICKING'"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.picked_package_count = plan_item.package_count")).isEqualTo(2); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index a3ceba32c..a7703c107 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -10,10 +10,33 @@ import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class PdaSplitSettlementSqlContractTest { + @Test + void addsPreloadConfirmationAuditColumnsForIncrementalPdaAllocation() throws Exception { + String moduleSql = new ClassPathResource("db/phase3-split-preload-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unifiedSql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + + assertThat(moduleSql) + .contains("confirmed_by BIGINT UNSIGNED NULL") + .contains("confirmed_by_name VARCHAR(64) NULL") + .contains("confirmed_at DATETIME(3) NULL") + .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_by'") + .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_by_name'") + .contains("phase3_split_add_column('fl_preload_plan', 'confirmed_at'"); + assertThat(unifiedSql) + .contains("CREATE PROCEDURE migrate_preload_confirmation_columns()") + .contains("ADD COLUMN confirmed_by BIGINT UNSIGNED NULL") + .contains("ADD COLUMN confirmed_by_name VARCHAR(64) NULL") + .contains("ADD COLUMN confirmed_at DATETIME(3) NULL"); + } + @Test void protectsLoadedAndRemainderSettlement() throws Exception { String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") @@ -77,6 +100,7 @@ class PdaSplitSettlementSqlContractTest { assertThat(clone) .contains("#{childOrderId},#{childOrderNo}") .contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END") + .contains("business_type,fulfillment_type") .contains("actual_gross_weight_kg * #{packageCount}") .contains("actual_net_weight_kg * #{packageCount}"); @@ -112,6 +136,18 @@ class PdaSplitSettlementSqlContractTest { .contains("plan_item.package_count = pick_item.picked_package_count"); } + @Test + void zeroPackageSettlementNeverWritesNullVolumeWeight() throws Exception { + String update = String.join(" ", InboundSplitSettlementMapper.class.getMethod( + "updateMeasurementItemForLoaded", long.class, long.class, int.class) + .getAnnotation(Update.class).value()).replaceAll("\\s+", " "); + + assertThat(update) + .contains("volume_weight_kg=CASE WHEN #{packageCount}=0 THEN 0") + .contains("COALESCE(volume_weight_kg/NULLIF(package_count,0)*#{packageCount},0)"); + assertThat(update.indexOf("volume_weight_kg=")).isLessThan(update.indexOf("package_count=#{packageCount}")); + } + @Test void keepsAllocationConfirmedUntilReleaseStep() throws Exception { String consume = InboundPalletAllocationMapper.class.getMethod("consume", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 62fcaae25..b29dba168 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -84,6 +84,8 @@ class Phase3FinalUnifiedSqlContractTest { .contains("platform:firstleg:outbound-dispatch:%") .contains("platform:firstleg:customs:%") .contains("platform:firstleg:port-schedule:%") + .contains("id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3") + .contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36") .contains("platform:firstleg:pda:%") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("角色授权未完整写入"); @@ -149,7 +151,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务表") .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); - assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150); + assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(152); assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459); assertThat(sql) .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") @@ -210,6 +212,10 @@ class Phase3FinalUnifiedSqlContractTest { assertThat(sql) .doesNotContain("\n ADD COLUMN IF NOT EXISTS") .doesNotContain("\n DROP CHECK IF EXISTS") + .doesNotContain("\n+--") + .doesNotContain("\n---") + .doesNotContain("\n>>>") + .doesNotContain("\n<<<") .contains("CREATE PROCEDURE phase3_pda_add_column") .contains("CREATE PROCEDURE phase3_pda_replace_operation_check"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index fbeb77491..f6477f5de 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.repository; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; +import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; @@ -9,6 +10,12 @@ import java.lang.reflect.Method; import static org.assertj.core.api.Assertions.assertThat; class SmartLoadingCandidateMapperContractTest { + @Test + void candidateHasOnlyTheCanonicalMybatisConstructor() { + assertThat(Candidate.class.getDeclaredConstructors()).singleElement() + .satisfies(constructor -> assertThat(constructor.getParameterCount()).isEqualTo(18)); + } + @Test void selectsLatestApprovedBatch() throws Exception { Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("candidates", long.class); @@ -37,7 +44,7 @@ class SmartLoadingCandidateMapperContractTest { @Test void detailUsesSameBatchRule() throws Exception { Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod( - "candidate", long.class, long.class, long.class); + "candidate", long.class, long.class, long.class, long.class); String sql = String.join(" ", detail.getAnnotation(Select.class).value()); assertThat(sql) @@ -46,6 +53,12 @@ class SmartLoadingCandidateMapperContractTest { .contains("SELECT MAX(m2.id)") .contains("o.warehouse_id=#{warehouseId}") .contains("o.process_status='WAIT_SPLIT'") + .contains("o.process_status='LOADING'") + .contains("same_plan_item.loading_plan_id=#{planId}") + .contains("same_plan_item.account_id=o.tenant_id") + .contains("same_plan_item.inbound_id=o.id") + .contains("same_plan_item.deleted=0") + .doesNotContain("o.process_status IN ('WAIT_SPLIT','LOADING')") .contains("unitGrossWeightKg") .contains("unitNetWeightKg") .doesNotContain("i.unit_weight_kg") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 8f4302c98..e5c1ddc08 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -76,6 +76,19 @@ class SmartLoadingSqlContractTest { .contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); } + @Test + @DisplayName("存量货柜迁移补齐BaseMapper所需客户快照列") + void shouldMigrateContainerCustomerSnapshotColumns() throws Exception { + for (String sql : Set.of(migration(), unifiedMigration())) { + assertThat(sql) + .contains("ADD COLUMN customer_id BIGINT UNSIGNED NULL") + .contains("ADD COLUMN customer_name VARCHAR(128) NULL") + .contains("JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id") + .contains("container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id)") + .contains("container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)"); + } + } + @Test @DisplayName("同一入库明细的多条单箱测量记录只生成一个候选") void shouldAggregateCartonMeasurementRowsByInboundDetail() throws Exception { @@ -138,8 +151,8 @@ class SmartLoadingSqlContractTest { .contains("CREATE TRIGGER trg_container_booking_before_update") .contains("current_plan_status IN ('LOADING','COMPLETED')") .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')"); + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')"); } private static void assertSimpleCustomsDocumentContract(String sql) { @@ -148,8 +161,8 @@ class SmartLoadingSqlContractTest { .contains("file_hash VARCHAR(64) NULL") .contains("completed_by BIGINT UNSIGNED NULL") .contains("completed_at DATETIME(3) NULL") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") .doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读"); } @@ -195,8 +208,6 @@ class SmartLoadingSqlContractTest { .contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'") .contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN") .contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") - .contains("LEFT JOIN fl_loading_pick_item pick_item") - .contains("pick_item.picked_package_count <> item.package_count") .contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") @@ -213,6 +224,23 @@ class SmartLoadingSqlContractTest { .doesNotContain("CONFIRMED") .doesNotContain("ROLLED_BACK") .doesNotContain("WITHDRAWN"); + assertLoadingCompletionTrigger(migration()); + assertLoadingCompletionTrigger(unifiedMigration()); + } + + private static void assertLoadingCompletionTrigger(String sql) { + assertThat(sql) + .contains("AND NOT EXISTS (") + .contains("pick_task.loading_plan_id = OLD.id") + .contains("pick_task.task_status = 'LOADING'") + .contains("pick_item.item_status = 'PICKED'") + .contains("pick_item.item_status = 'PARTIAL'") + .contains("pick_item.picked_package_count > 0") + .contains("pick_item.item_status = 'SKIPPED'") + .contains("pick_item.picked_package_count = 0") + .doesNotContain("pick_item.picked_package_count <> item.package_count"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.picked_package_count = item.package_count")).isEqualTo(2); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java index 0a7ea777f..114968d92 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -47,7 +47,13 @@ class ContainerOverviewServiceTest { 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()), new ContainerOverviewMapper.PhotoRow( 72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now()))); - when(mapper.documents(41L)).thenReturn(java.util.List.of()); + com.baiyue.firstleg.entity.ContainerDocumentEntity customs = new com.baiyue.firstleg.entity.ContainerDocumentEntity(); + customs.setId(103L); customs.setDocumentType("CUSTOMS_DECLARATION"); customs.setFileId(104L); + customs.setFileName("customs.pdf"); customs.setVersion(3); + com.baiyue.firstleg.entity.ContainerDocumentEntity clearance = new com.baiyue.firstleg.entity.ContainerDocumentEntity(); + clearance.setId(105L); clearance.setDocumentType("DESTINATION_CLEARANCE"); clearance.setFileId(106L); + clearance.setFileName("clearance.pdf"); clearance.setVersion(1); + when(mapper.documents(41L)).thenReturn(java.util.List.of(customs, clearance)); when(mapper.exceptions(41L)).thenReturn(java.util.List.of()); when(mapper.transport(41L)).thenReturn(java.util.List.of()); when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of()); @@ -66,6 +72,15 @@ class ContainerOverviewServiceTest { assertThat(count.evidenceType()).isEqualTo("LOADING_DOOR_OPEN"); assertThat(count.count()).isEqualTo(1); }); + assertThat(overview.documents()).extracting(document -> document.documentType()) + .containsExactly("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); + assertThat(overview.documents()).filteredOn(document -> "CUSTOMS_DECLARATION".equals(document.documentType())) + .singleElement().satisfies(document -> assertThat(document.version()).isEqualTo(3)); + assertThat(overview.documents()).filteredOn(document -> "DESTINATION_CLEARANCE".equals(document.documentType())) + .singleElement().satisfies(document -> { + assertThat(document.fileId()).isEqualTo(106L); + assertThat(document.version()).isEqualTo(1); + }); } @Test void downloadsOnlyActivePhotoOwnedByContainerLoadingPlan() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 9ac608ba6..4dba3b293 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -17,8 +17,8 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; @@ -51,7 +51,7 @@ class ContainerServiceTest { @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean private LoadingPickRepository loadingPickRepository; @MockBean private LoadingPickService loadingPickService; - @MockBean private PriceBookQueryRepository priceBooks; + @MockBean private PriceBookQueryUseCase priceBooks; @MockBean private OceanBookingRepository oceanBookings; @MockBean private WarehouseSupplierSnapshotService suppliers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; @@ -288,7 +288,7 @@ class ContainerServiceTest { void linksReleasedBookingAfterPreloading() { ContainerBookingEntity container = booking("EDITING", 2); container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC"); - container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000")); + container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000")); OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); @@ -296,7 +296,7 @@ class ContainerServiceTest { when(repository.find(11L)).thenReturn(container, container); when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); - when(oceanBookings.allocations(21L)).thenReturn(List.of()); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"), same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1); @@ -311,6 +311,24 @@ class ContainerServiceTest { verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class)); } + @Test + @DisplayName("首次关联拒绝装柜仓库不一致") + void rejectsLinkWhenWarehouseDiffers() { + assertThreeFieldMismatch(102L, "SEA-US", "40HC"); + } + + @Test + @DisplayName("首次关联拒绝物流渠道不一致") + void rejectsLinkWhenChannelDiffers() { + assertThreeFieldMismatch(101L, "SEA-EU", "40HC"); + } + + @Test + @DisplayName("首次关联拒绝柜型不一致") + void rejectsLinkWhenContainerTypeDiffers() { + assertThreeFieldMismatch(101L, "SEA-US", "45HC"); + } + @Test @DisplayName("未发布订舱不可关联") void rejectsDraftBooking() { @@ -333,12 +351,10 @@ class ContainerServiceTest { booking.setId(21L); booking.setStatus("RELEASED"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); requirement.setId(31L); requirement.setQuantity(1); - OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); - allocation.setRequirementId(31L); when(repository.find(11L)).thenReturn(container); when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); - when(oceanBookings.allocations(21L)).thenReturn(List.of(allocation)); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L); assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) @@ -347,6 +363,28 @@ class ContainerServiceTest { any(), anyInt(), anyLong(), anyString()); } + private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel, + String requirementType) { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setWarehouseId(101L); container.setChannelCode("SEA-US"); + container.setContainerType("40HC"); + OceanBookingEntity oceanBooking = new OceanBookingEntity(); + oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); + oceanBooking.setWarehouseId(bookingWarehouseId); oceanBooking.setChannelCode(bookingChannel); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType(requirementType); + when(repository.find(11L)).thenReturn(container); + when(oceanBookings.find(21L)).thenReturn(oceanBooking); + when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致"); + verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), + any(), anyInt(), anyLong(), anyString()); + } + @Test @DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜") void unlinksEmptySimulationDirectly() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java index 6f2434cf3..33616c9f5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java @@ -4,7 +4,7 @@ import com.baiyue.firstleg.domain.CustomerDimensionBoxType; import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 823e35dd3..0cefc7b02 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -11,7 +11,7 @@ import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.repository.FbaPricingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java index 98ccc8e15..51664c9e3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidatorTest.java @@ -37,6 +37,14 @@ class FeePaymentSnapshotValidatorTest { .isInstanceOf(BizException.class).hasMessageContaining("账期应付不得"); } + @Test + void payableWithoutSettlementModeReturnsBusinessValidationInsteadOfNullPointer() { + assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", null, + null, null, null, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("应付费用必须选择ON_ACCOUNT或DIRECT_PAID"); + } + @Test void directPaidRequiresTimeMethodAndReferenceOrVoucher() { LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index 9f6c94123..a9583fa1b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -72,11 +72,35 @@ class InboundOrderCreationServiceTest { assertThat(inboundCaptor.getValue().getCarrierId()).isNull(); assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); assertThat(inboundCaptor.getValue().getPricingTaxMode()).isEqualTo("TAX_EXCLUDED"); + assertThat(inboundCaptor.getValue().getFulfillmentType()).isEqualTo("STANDARD"); assertThat(inboundCaptor.getValue().getTotalPackageCount()).isEqualTo(4); assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId()); assertThat(replayedId).isEqualTo(900L); } + @Test + void persistsDropshipFulfillmentTypeWithoutChangingPricingBusinessType() { + OrderApprovedInboundCommand base = approvedCommand("PCHK2608140001"); + OrderApprovedInboundCommand command = new OrderApprovedInboundCommand( + base.tenantId(), base.orderId(), base.orderNo(), base.inboundCode(), base.customerId(), + base.customerName(), base.warehouseId(), base.warehouseName(), base.deliveryWarehouseId(), + base.deliveryWarehouseName(), base.expectedArrivalAt(), base.businessType(), base.channelCode(), + base.channelName(), base.carrierId(), base.carrierName(), base.taxMode(), base.pricingTaxMode(), + base.containerMode(), base.customsInspection(), base.destinationType(), base.destinationWarehouseId(), + base.destinationCode(), base.destinationName(), base.publicRemark(), base.expressCompanyName(), + base.expressTrackingNo(), base.totalPackageCount(), base.pickupMethod(), base.requestedPickupAt(), + "ONE_CLICK_DROPSHIP"); + when(orders.selectOne(any(Wrapper.class))).thenReturn(null); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + service.createFromApprovedOrder(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOrderEntity.class); + verify(orders).insert(captor.capture()); + assertThat(captor.getValue().getBusinessType()).isEqualTo("FBA"); + assertThat(captor.getValue().getFulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP"); + } + @Test void shouldRejectReplayWithDifferentInboundCode() { InboundOrderEntity existing = inbound(900L, "RK-OLD", "UNPRICED", null, null); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java new file mode 100644 index 000000000..3970763f6 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java @@ -0,0 +1,135 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.client.EventBridgeClient; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LoadingCompletedOutboxRelayTest { + @Mock private LoadingCompletedOutboxRepository repository; + @Mock private EventBridgeClient client; + private ObjectMapper objectMapper; + private LoadingCompletedOutboxRelay relay; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + relay = new LoadingCompletedOutboxRelay(); + ReflectionTestUtils.setField(relay, "repository", repository); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "leaseSeconds", 60L); + ReflectionTestUtils.setField(relay, "retrySeconds", 30L); + } + + @Test + void claimsExpiredLeasesAndPublishesAfterOverseasBridgeAcknowledgement() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( + new EventBridgeClient.Acknowledgement(event.eventId(), "已接收", "2026-08-20T03:01:00"))); + + relay.relay(); + + verify(repository).published(eq(9001L), anyString(), any(LocalDateTime.class)); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + } + + @Test + void redactsInternalTokenBeforePersistingRetryError() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))) + .thenThrow(new IllegalStateException("failed internal-token\nremote")); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), + eq("IllegalStateException: failed *** remote")); + } + + @Test + void persistsRootCauseWhenCircuitBreakerWrapsBridgeFailure() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenThrow( + new NoFallbackAvailableException("No fallback available.", + new IllegalStateException("Connection refused: event-bridge-cn:9312"))); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), + eq("IllegalStateException: Connection refused: event-bridge-cn:9312")); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + @Test + void persistsDownstreamCodeAndRequestEvidenceWhenBridgeRejectsEvent() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.failure( + ResultCode.SERVICE_UNAVAILABLE, "bridge inbox unavailable", "request-123", "trace-456")); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq( + "IllegalStateException: 事件桥拒绝头程跨区事件: code=S0503, message=bridge inbox unavailable, " + + "requestId=request-123, traceId=trace-456")); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + private InboundOutboxEntity row(EventEnvelope event) throws Exception { + InboundOutboxEntity row = new InboundOutboxEntity(); + row.setId(9001L); + row.setEventKey(event.eventId()); + row.setPayloadJson(objectMapper.writeValueAsString(event)); + return row; + } + + private EventEnvelope event() { + var payload = objectMapper.valueToTree(new FirstLegLoadingPlanCompletedV1( + "LOAD-1", "PLAN-1", "BOOK-1", "MSCU1234567", null, + "WH-1", "Warehouse", List.of())); + return new EventEnvelope("00000000-0000-4000-8000-000000000019", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1, + Instant.parse("2026-08-20T03:00:01Z"), RegionCode.CN, RegionCode.OVERSEAS, "1", + "LOADING_PLAN", "LOAD-1", 7L, 1L, "trace-12345678", "correlation-12345678", null, + payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 6b480b998..50ff2452d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -6,6 +6,9 @@ import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -17,20 +20,27 @@ import java.math.BigDecimal; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class LoadingCompletedOutboxServiceTest { private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class); private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class); + private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class); private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService(); @BeforeEach void setup() { ReflectionTestUtils.setField(service,"snapshotMapper",snapshots); + ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests); ReflectionTestUtils.setField(service,"outboxMapper",outbox); ReflectionTestUtils.setField(service,"objectMapper",mapper); + when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of( + label(101, "BOX-101"), label(102, "BOX-102"), + label(202, "BOX-202"), label(302, "BOX-302"))); } @Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception { @@ -46,16 +56,164 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().get("orders")).hasSize(2); assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100"); assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); + assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101"); + assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101"); + assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } + @Test void rejectsOrdinaryItemWhenSkuToCartonMappingCannotBeBuilt() { + Row value = row(11, 100, 101); + value.setSkuCode(null); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + assertThatThrownBy(() -> service.publish(90L)) + .hasMessageContaining("缺少 SKU"); + } + + @Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception { + Row value = row(11, 100, 101); + value.setDestinationType("FBA"); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/destinationType").asText()).isEqualTo("FBA"); + assertThat(event.payload().at("/orders/0/items/0/cartonMarks")).isEmpty(); + verify(manifests, org.mockito.Mockito.never()).labelPool(11L, 100L); + } + + @Test void rejectsUnfrozenDestinationType() { + Row value = row(11, 100, 101); + value.setDestinationType(null); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + assertThatThrownBy(() -> service.publish(90L)) + .hasMessageContaining("目的类型必须唯一且为 FBA 或 OVERSEAS"); + verify(outbox, org.mockito.Mockito.never()).insert( + org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); + } + + @Test void freezesExactlyLoadedPackagesFromLabelPoolDeterministically() throws Exception { + Row value = row(11, 100, 101); + value.setPackageCount(2); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + when(manifests.labelPool(11L, 100L)).thenReturn(List.of( + label(101, "BOX-001"), label(101, "BOX-002"), + label(101, "BOX-003"), label(101, "BOX-004"))); + + service.publish(90L); + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture()); + EventEnvelope first = mapper.readValue(captor.getAllValues().get(0).getPayloadJson(), EventEnvelope.class); + EventEnvelope second = mapper.readValue(captor.getAllValues().get(1).getPayloadJson(), EventEnvelope.class); + assertThat(first.payload().at("/orders/0/items/0/cartonMarks")).hasSize(2); + assertThat(first.payload().at("/orders/0/items/0/cartonMarks")) + .isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks")); + } + + @Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception { + Row first = row(11, 100, 101); first.setPackageCount(1); + Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(manifests.labelPool(11L, 100L)).thenReturn(List.of( + label(201, "BOX-001"), label(202, "BOX-002"), label(203, "BOX-003"))); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + List firstMarks = mapper.convertValue(event.payload().at("/orders/0/items/0/cartonMarks"), List.class); + List secondMarks = mapper.convertValue(event.payload().at("/orders/0/items/1/cartonMarks"), List.class); + assertThat(firstMarks).hasSize(1); + assertThat(secondMarks).hasSize(2); + assertThat(firstMarks).doesNotContainAnyElementsOf(secondMarks); + } + @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { - String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) + String rowsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) + .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); + String palletsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("palletRows", long.class) .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); - assertThat(sql).contains("p.plan_status = 'COMPLETED'") + assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'") .contains("o.process_status = 'LOADED'"); + for (String sql : List.of(rowsSql, palletsSql)) { + assertThat(sql) + .contains("actual_pick.loading_plan_item_id = i.id") + .contains("actual_pick.item_status IN ('PICKED','PARTIAL')") + .contains("actual_pick.picked_package_count > 0") + .contains("actual_pick.picked_package_count = i.package_count") + .contains("actual_task.id = actual_pick.pick_task_id") + .contains("actual_task.loading_plan_id =") + .doesNotContain("'SKIPPED'") + .doesNotContain("'PENDING'") + .doesNotContain("'PICKING'"); + } + assertThat(palletsSql) + .contains("label.tenant_id = i.account_id") + .contains("label.inbound_detail_id = i.inbound_detail_id") + .contains("label.batch_status = 'ACTIVE'") + .doesNotContain("label.inbound_id = i.inbound_id"); + } + + @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { + Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP"); + first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A"); + Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP"); + second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B"); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(snapshots.palletRows(90L)).thenReturn(List.of( + pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-001"), + pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-002"), + pallet(11,100,2,"PLT-02",1,102,"SKU-B",1,5,"BOX-003"))); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP"); + assertThat(event.payload().at("/orders/0/customerId").asText()).isEqualTo("11"); + assertThat(event.payload().at("/orders/0/pallets")).hasSize(2); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); + assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); + } + + @Test + void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception { + Row value = row(11, 100, 101); + value.setBusinessType("ONE_CLICK_DROPSHIP"); + value.setFulfillmentType("STANDARD"); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/orderType").asText()).isEqualTo("FIRST_LEG"); + assertThat(event.payload().at("/orders/0/pallets")).isEmpty(); + } + + @Test + void rejectsMixedFulfillmentTypesWithinOneInbound() { + Row first = row(11, 100, 101); + Row second = row(11, 100, 102); + second.setFulfillmentType("ONE_CLICK_DROPSHIP"); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + + assertThatThrownBy(() -> service.publish(90L)) + .hasMessageContaining("履约类型必须唯一"); + verify(outbox, org.mockito.Mockito.never()).insert( + org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); } private Row row(long account,long inbound,long detail) { @@ -63,9 +221,24 @@ class LoadingCompletedOutboxServiceTest { r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); r.setWarehouseName("WH"); r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L); r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L); - r.setInboundCode("RK"+inbound); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); + r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail); + r.setDestinationType("OVERSEAS"); + r.setFulfillmentType("STANDARD"); r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r; } + + + private PalletRow pallet(long account,long inbound,int allocation,String palletNo,int allocated, + long detail,String sku,int packages,int quantity,String boxCode) { + PalletRow row = new PalletRow(); row.setAccountId(account); row.setSourceInboundId(inbound); + row.setAllocationNo(allocation); row.setPalletCode(palletNo); row.setAllocatedPackageCount(allocated); + row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品"); + row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row; + } + + private LabelRow label(long detailId, String boxCode) { + LabelRow row = new LabelRow(); row.setInboundDetailId(detailId); row.setBoxCode(boxCode); return row; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index aac76e2e6..212b488d3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -36,7 +36,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -450,16 +452,16 @@ class LoadingListServiceTest { String contentHash = sha256Bytes(content); var file = new MockMultipartFile("file", "customs.pdf", "application/pdf", content); var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L, - "LOADING_CUSTOMS_DOCUMENT", "10", "customs.pdf", contentHash, "application/pdf", + "LOADING_CUSTOMS_DECLARATION", "10", "customs.pdf", contentHash, "application/pdf", (long) content.length); ContainerDocumentEntity saved = document(41L, LoadingListService.CUSTOMS_DECLARATION, 0, false); saved.setFileId(101L); saved.setFileName("customs.pdf"); saved.setFileHash(contentHash); when(repository.aggregate(10L)).thenReturn(plan); when(repository.history(anyString())).thenReturn(null); when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(null, saved); - when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(file))).thenReturn(ApiResponse.success(metadata)); - when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(101L))).thenReturn(ApiResponse.success(metadata)); when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1); @@ -467,10 +469,108 @@ class LoadingListServiceTest { 10L, 100L, 9L, "operator", "customs-upload", 0, null, file); assertThat(result.documentType()).isEqualTo(LoadingListService.CUSTOMS_DECLARATION); - verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(file)); } + @Test + void downloadsCompletedLoadingDeclarationWithExactLongFileOwnership() { + long planId = 9_007_199_254_740_993L; + long fileId = 9_007_199_254_740_995L; + AggregateRow plan = row("COMPLETED", 4); + plan.setPlanId(planId); + ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true); + document.setFileId(fileId); + document.setFileName("出口报关单.pdf"); + byte[] content = new byte[]{1, 2, 3}; + var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L, + "LOADING_CUSTOMS_DECLARATION", Long.toString(planId), "出口报关单.pdf", hash(), + "application/pdf", (long) content.length); + when(repository.aggregate(planId)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document); + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq(Long.toString(planId)), eq(fileId))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq(Long.toString(planId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content)); + + var result = service.downloadLoadingDeclaration(planId, 100L, fileId); + + assertThat(result.fileName()).isEqualTo("出口报关单.pdf"); + assertThat(result.content()).containsExactly(1, 2, 3); + verify(documentFiles).download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq("9007199254740993"), eq(fileId)); + } + + @Test + void rejectsNonCurrentLoadingDeclarationFileIdBeforeFileService() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregate(10L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)) + .thenReturn(document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true)); + + assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 100L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + + @Test + void rejectsLoadingDeclarationFileWithMismatchedBusinessOwnership() { + AggregateRow plan = row("COMPLETED", 4); + ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true); + when(repository.aggregate(10L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document); + var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L, + "LOADING_CUSTOMS_DECLARATION", "other-plan", "customs.pdf", hash(), + "application/pdf", 3L); + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq("10"), eq(99L))).thenReturn(ApiResponse.success(metadata)); + + assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 99L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).download(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + + @Test + void downloadsCurrentDestinationClearanceWithExactContainerOwnership() { + long bookingId = 9_007_199_254_740_993L; + long fileId = 9_007_199_254_740_995L; + AggregateRow plan = row("COMPLETED", 4); + ContainerDocumentEntity document = document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true); + document.setContainerBookingId(bookingId); + document.setFileId(fileId); + document.setFileName("destination-clearance.pdf"); + byte[] content = new byte[]{4, 5, 6}; + var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L, + "CONTAINER_CLEARANCE_DOCUMENT", Long.toString(bookingId), "destination-clearance.pdf", + hash(), "application/pdf", (long) content.length); + when(repository.aggregateByBooking(bookingId)).thenReturn(plan); + when(repository.document(bookingId, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(document); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq(Long.toString(bookingId)), eq(fileId))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.download(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq(Long.toString(bookingId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content)); + + var result = service.downloadContainerClearance(bookingId, fileId); + + assertThat(result.fileName()).isEqualTo("destination-clearance.pdf"); + assertThat(result.content()).containsExactly(4, 5, 6); + } + + @Test + void rejectsNonCurrentDestinationClearanceFileIdAsNotFound() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)) + .thenReturn(document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true)); + + assertThatThrownBy(() -> service.downloadContainerClearance(30L, 100L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + @Test void rejectsReusedIdempotencyKeyWithDifferentAttachRequest() { AggregateRow plan = row("LOADING", 2); @@ -499,6 +599,125 @@ class LoadingListServiceTest { verify(repository, never()).aggregateByBooking(30L); } + @Test + void firstDeliveryOrderUploadPersistsDocumentAndImmutablePlanHistory() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L, + "CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length); + ContainerDocumentEntity saved = document(51L, LoadingListService.DELIVERY_ORDER, 0, false); + saved.setFileId(101L); saved.setFileName("do.pdf"); saved.setFileHash(contentHash); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(101L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(any())).thenReturn(1); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER, + 9L, "operator", "do-first", 0, null, file); + + assertThat(result).extracting(value -> value.documentType(), value -> value.fileId(), value -> value.version()) + .containsExactly(LoadingListService.DELIVERY_ORDER, 101L, 0); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_DELIVERY_ORDER"), + eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); + } + + @Test + void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false); + ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false); + saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash); + var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L, + "CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"), + eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata)); + when(repository.replaceDocument(any())).thenReturn(1); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER, + 9L, "operator", "pu-update", 2, null, file); + + assertThat(result.fileId()).isEqualTo(102L); + assertThat(result.version()).isEqualTo(3); + verify(repository).replaceDocument(any()); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"), + eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); + } + + @Test + void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)) + .thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 2, false)); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes()); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-stale", 1, null, file)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void sameDeliveryOrderUploadKeyReplaysWithoutUploadingAgain() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String requestHash = sha256("UPLOAD|DELIVERY_ORDER|30|0|null|" + sha256Bytes(content)); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"" + requestHash + "\"}"); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(replay); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)) + .thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 0, false)); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER, + 9L, "operator", "do-replay", 0, null, file); + + assertThat(result.documentType()).isEqualTo(LoadingListService.DELIVERY_ORDER); + verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void deliveryOrderDatabaseConstraintFailureReturnsBusinessConflictInsteadOfUnknownError() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + var metadata = new LoadingDocumentFileClient.Metadata(103L, 0L, + "CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(103L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(any())).thenThrow( + new org.springframework.dao.DataIntegrityViolationException("trigger rejected")); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-db-fail", 0, null, file)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT); + } + + @Test void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() { AggregateRow before = row("EDITING", 3); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 2c47d3736..437c62dcd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -508,9 +508,12 @@ class LoadingPickServiceTest { when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); - when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); + var incrementalOwner = new LoadingPickItemMapper.InboundOwner(101L, 60L); + when(repository.inboundOwners(20L)).thenReturn(List.of(owner, incrementalOwner)); when(repository.inboundForUpdate(101L, 50L)).thenReturn( new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); + when(repository.inboundForUpdate(101L, 60L)).thenReturn( + new LoadingInboundMapper.Snapshot(60L, 101L, "LOADING", 3)); service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator"); service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator"); @@ -519,14 +522,17 @@ class LoadingPickServiceTest { verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(settlementService).settle(20L, 9L, "operator"); verify(repository).syncInboundProjections(20L, 9L, "operator"); - verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L, 50L); + verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L, 50L); verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); - verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-1-50", "checked"); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator", + "PDA-complete-1-60", "checked"); verify(loadingListService).publishCompletedEvent(10L); InOrder finalization = inOrder(loadingListService, loadingDeliveryService); finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); - finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-1-50", "checked"); finalization.verify(loadingListService).publishCompletedEvent(10L); } @@ -556,7 +562,7 @@ class LoadingPickServiceTest { verify(settlementService).settle(20L, 9L, "operator"); verify(repository).approve(20L, 9L, "operator", "checked"); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); - verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-not-blocked-50", "checked"); verify(loadingListService).publishCompletedEvent(10L); } @@ -574,7 +580,7 @@ class LoadingPickServiceTest { new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单并发修改")) - .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", "PDA-complete-finalize-fails-50", "checked"); assertThatThrownBy(() -> service.complete(20L, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 9412f0928..8f6329212 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -13,11 +13,11 @@ import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator; import com.baiyue.firstleg.pricing.PriceBookWarehouseRegionResolver; import com.baiyue.firstleg.pricing.PriceRegionResolver; import com.baiyue.firstleg.pricing.RegionWeightTierEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; @@ -38,7 +38,7 @@ import static org.mockito.Mockito.when; PriceBookWarehouseRegionResolver.class}) @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MerchantPriceBookServiceTest { - @MockBean private PriceBookQueryRepository repository; + @MockBean private PriceBookQueryUseCase repository; @MockBean private PriceBookWarehouseClient warehouses; @Resource private MerchantPriceBookService service; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java new file mode 100644 index 000000000..037490b0e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -0,0 +1,108 @@ +package com.baiyue.firstleg.service; + +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.OverseasContainerUnloadedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.ContainerEventInboxEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OverseasContainerEventConsumerTest { + @Mock private ContainerBookingMapper containers; + @Mock private ContainerEventInboxMapper inbox; + private ObjectMapper objectMapper; + private OverseasContainerEventConsumer consumer; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + consumer = new OverseasContainerEventConsumer(containers, inbox, objectMapper); + } + + @Test + void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() { + ContainerBookingEntity container = container(); + when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container); + Instant completedAt = Instant.parse("2026-08-20T03:00:00Z"); + Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z"); + + consumer.consume(event("00000000-0000-4000-8000-000000000001", 7, + new OverseasContainerUnloadedV1("1001", "MSCU1234567", completedAt, uploadedAt, null))); + + verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")), + eq(LocalDateTime.parse("2026-08-20T02:30:00")), + eq(LocalDateTime.parse("2026-08-22T02:30:00")), isNull(), eq(7L)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class); + verify(inbox).insert(captor.capture()); + assertThat(captor.getValue().getAggregateId()).isEqualTo("1001"); + assertThat(captor.getValue().getStatus()).isEqualTo("PROCESSED"); + verify(inbox).updateById(captor.getValue()); + } + + @Test + void repeatedEventWithSameHashDoesNotApplyAgain() { + OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567", + Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); + EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload); + ContainerEventInboxEntity existing = new ContainerEventInboxEntity(); + existing.setPayloadHash(event.payloadHash()); + when(inbox.selectById(event.eventId())).thenReturn(existing); + when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container()); + + consumer.consume(event); + + verify(containers, never()).applyEmptyReport(anyLong(), any(), any(), any(), any(), anyLong()); + verify(inbox, never()).insert(any(ContainerEventInboxEntity.class)); + } + + @Test + void rejectsTamperedPayloadHash() { + EventEnvelope valid = event("00000000-0000-4000-8000-000000000003", 7, + new OverseasContainerUnloadedV1("1001", "MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), + Instant.parse("2026-08-20T02:30:00Z"), null)); + EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(), + valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(), + valid.aggregateId(), valid.aggregateVersion(), valid.handoverVersion(), valid.traceId(), + valid.correlationId(), valid.causationId(), valid.payload(), "sha256:" + "0".repeat(64)); + + assertThatThrownBy(() -> consumer.consume(tampered)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("payloadHash"); + verifyNoInteractions(containers, inbox); + } + + private EventEnvelope event(String eventId, long aggregateVersion, OverseasContainerUnloadedV1 payload) { + var node = objectMapper.valueToTree(payload); + String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + return new EventEnvelope(eventId, EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, + Instant.parse("2026-08-20T02:31:00Z"), RegionCode.OVERSEAS, RegionCode.CN, "1", + "CONTAINER_BOOKING", payload.sourceContainerBookingId(), aggregateVersion, null, + "trace-12345678", "correlation-12345678", null, node, hash); + } + + private ContainerBookingEntity container() { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(1001L); value.setContainerNo("MSCU1234567"); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index 306cbe216..131a02042 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -66,6 +66,9 @@ class PdaPickSettlementServiceTest { verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(), eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-1"), eq(12), eq(1), eq("REMAINDER"), eq(9L), eq("operator")); + verify(splits).transferRemainingCartonLabels(eq(10001L), eq(31L), anyLong(), + eq(1002L), eq("IN-001-1"), eq(8), eq(12)); + verify(splits).normalizeLoadedCartonLabels(10001L, 31L, 8); verify(splits).markSourceLoaded(10001L, 31L, 8, 9L, 3); verify(allocations).consume(10001L, 31L, 1L, 8); verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request -> diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 5869f9f9c..8f39dfa00 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -42,6 +42,7 @@ import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.catchThrowableOfType; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -241,7 +242,7 @@ class SmartLoadingServiceTest { LoadingPlanEntity plan = plan("EDITING", 0); when(repository.plan(1)).thenReturn(plan); when(repository.booking(99L)).thenReturn(booking()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(5)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(5)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4)); BizException error = catchThrowableOfType(() -> service.assign(1, @@ -258,7 +259,7 @@ class SmartLoadingServiceTest { linked.setCustomerId(88L); when(repository.plan(1L)).thenReturn(plan); when(repository.booking(99L)).thenReturn(linked); - when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(5)); + when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(5)); BizException error = catchThrowableOfType(() -> service.assign(1L, new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class); @@ -356,7 +357,7 @@ class SmartLoadingServiceTest { when(repository.booking(99)).thenReturn(booking()); when(repository.touch(1, 5, 7, "u")).thenReturn(1); when(repository.items(1)).thenReturn(List.of()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(10)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); when(loadingPickService.mutationSnapshot(1L)).thenReturn( new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of())); @@ -367,6 +368,7 @@ class SmartLoadingServiceTest { Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u"); assertThat(result.status()).isEqualTo("LOADING"); + verify(repository).candidate(1L, 100L, 10L, 20L); var order = inOrder(loadingPickService, splitPreloadService, repository, loadingModelBridge); order.verify(loadingPickService).lockMutable(1L); order.verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u"); @@ -374,7 +376,8 @@ class SmartLoadingServiceTest { order.verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L, 7L, "u", "assign-loading"); order.verify(loadingPickService).sync(eq(1L), any(LoadingPlanItemEntity.class)); - verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u"); + verify(splitPreloadService, never()).ensurePendingForLoadingAllocation( + anyLong(), anyLong(), anyLong(), anyString()); } @Test @@ -391,7 +394,7 @@ class SmartLoadingServiceTest { Plan result = service.assign(1, request, 7, "u"); assertThat(result.status()).isEqualTo("LOADING"); - verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong()); verify(cargoClient, never()).detail(anyString(), anyLong(), anyLong(), anyLong()); } @@ -590,7 +593,7 @@ class SmartLoadingServiceTest { when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3)); when(repository.item(1L, 8L)).thenReturn(line); when(repository.booking(99L)).thenReturn(booking()); - when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(10)); + when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10)); when(repository.items(1L)).thenReturn(List.of(line)); when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); when(repository.update(line)).thenReturn(1); @@ -602,6 +605,23 @@ class SmartLoadingServiceTest { assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱"); } + @Test + void pickedQuantityConflictPrecedesCandidateAvailabilityValidation() { + LoadingPlanEntity plan = plan("LOADING", 2); + LoadingPlanItemEntity line = item(8, 9, 20, 2); + when(repository.plan(1L)).thenReturn(plan); + when(repository.item(1L, 8L)).thenReturn(line); + when(loadingPickService.mutationSnapshot(1L)).thenReturn( + new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of(8L, 2))); + + assertThatThrownBy(() -> service.adjust(1L, 8L, + new AdjustRequest(1, 2, 0, "adjust-picked"), 7L, "u")) + .hasMessageContaining("不能低于已拣货箱数"); + + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong()); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); + } + @Test void preservesDownstreamCode() { stubAssignment(); @@ -859,7 +879,7 @@ class SmartLoadingServiceTest { when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(10)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); when(repository.touch(1, 0, 7, "u")).thenReturn(1); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java new file mode 100644 index 000000000..e593f1aa2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -0,0 +1,119 @@ +package com.baiyue.firstleg.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(classes = TaillegRealProducerAcceptanceTest.AcceptanceApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { + "spring.sql.init.mode=never", "spring.cloud.nacos.discovery.enabled=false", + "spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.config.enabled=false", + "spring.cloud.nacos.config.import-check.enabled=false", "spring.task.scheduling.enabled=false" + }) +@EnabledIfSystemProperty(named = "tailleg.real.producer", matches = "true") +class TaillegRealProducerAcceptanceTest { + static final long CUSTOMER = 990022L; + static final long BOOKING = 990022021L; + static final long PLAN = 990022022L; + static final long FIRST_INBOUND = 990022121L; + static final long DROPSHIP_INBOUND = 990022122L; + static final String MARKER = "REAL-TAIL-20260822-R3"; + + @Autowired JdbcTemplate jdbc; + @Autowired LoadingCompletedOutboxService outbox; + + @SpringBootConfiguration + @EnableAutoConfiguration(excludeName = "com.baiyue.common.security.BaiyueSecurityAutoConfiguration") + @Import(LoadingCompletedOutboxService.class) + @MapperScan("com.baiyue.firstleg.mapper") + static class AcceptanceApplication { } + + @Test + void completedLoadingPlanPublishesFirstLegAndDropshipOrders() { + String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + PLAN + ":" + CUSTOMER + ":2"; + Integer existing = jdbc.queryForObject("SELECT COUNT(*) FROM fl_inbound_outbox WHERE event_key=?", + Integer.class, eventKey); + if (existing == 0) { + seedCompletedLoadingPlan(); + outbox.publish(PLAN); + } + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM fl_inbound_outbox WHERE event_key=?", + Integer.class, eventKey)); + assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " + + "FROM fl_inbound_outbox WHERE event_key=?", Integer.class, + eventKey)); + } + + private void seedCompletedLoadingPlan() { + jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," + + "warehouse_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,1,1)", + BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ"); + jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," + + "updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING); + seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8"); + seedInbound(DROPSHIP_INBOUND, 990022222L, MARKER + "-DROP-IN", "FBA_SEA", "ONE_CLICK_DROPSHIP", "LOADED", "LAX9"); + seedItem(990022321L, FIRST_INBOUND, 990022421L, 990022221L, MARKER + "-FIRST-IN", "SKU-REAL-A", 3, "ONT8"); + seedItem(990022322L, DROPSHIP_INBOUND, 990022422L, 990022222L, MARKER + "-DROP-IN", "SKU-REAL-D", 2, "LAX9"); + for (int i = 1; i <= 3; i++) seedLabel(990022540L + i, FIRST_INBOUND, 990022221L, 990022421L, + MARKER + "-FIRST-IN", "FIRST-BOX-" + i, i, 3); + for (int i = 1; i <= 2; i++) seedLabel(990022550L + i, DROPSHIP_INBOUND, 990022222L, 990022422L, + MARKER + "-DROP-IN", "DROP-BOX-" + i, i, 2); + jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," + + "warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," + + "allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," + + "reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')", + 990022621L, CUSTOMER, DROPSHIP_INBOUND, 1, 92L, 0L, 990022721L, MARKER + "-PALLET-D", + 1.2, 0.4, 2, 2, 990022821L, "A-01", MARKER + "-RES"); + jdbc.update("INSERT INTO fl_loading_pick_task(id,loading_plan_id,container_booking_id,cabinet_no," + + "container_no,task_status,started_by,started_by_name) VALUES(?,?,?,?,?,'LOADING',1,'真实验收')", + 990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR"); + seedPickItem(990022911L, 990022321L, FIRST_INBOUND, 990022421L, 3); + seedPickItem(990022912L, 990022322L, DROPSHIP_INBOUND, 990022422L, 2); + jdbc.update("UPDATE fl_loading_plan SET plan_status='COMPLETED',confirmed_at=NOW(3),version=version+1 WHERE id=?", PLAN); + } + + private void seedPickItem(long id, long planItemId, long inboundId, long detailId, int packages) { + jdbc.update("INSERT INTO fl_loading_pick_item(id,pick_task_id,loading_plan_item_id,inbound_id," + + "inbound_detail_id,planned_package_count,picked_package_count,item_status) VALUES(?,?,?,?,?,?,?,'PICKED')", + id, 990022901L, planItemId, inboundId, detailId, packages, packages); + } + + private void seedInbound(long id, long orderId, String code, String businessType, String fulfillmentType, + String status, String destination) { + jdbc.update("INSERT IGNORE INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,process_status," + + "order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,fulfillment_type,destination_type,destination_warehouse_id," + + "destination_code,destination_name,package_count,actual_volume_cbm,created_by,updated_by) " + + "VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,?,'FBA',9001,?,?,?,0.4000,1,1)", + id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", businessType, + fulfillmentType, destination, "FBA " + destination, + fulfillmentType.equals("ONE_CLICK_DROPSHIP") ? 2 : 3); + } + + private void seedItem(long id, long inboundId, long detailId, long orderId, String inboundCode, + String sku, int packages, String destination) { + jdbc.update("INSERT IGNORE INTO fl_loading_plan_item(id,loading_plan_id,account_id,customer_name,inbound_id," + + "inbound_detail_id,order_id,order_no,inbound_code,destination_warehouse_id,destination_warehouse_code," + + "destination_warehouse_name,sku_code,product_name_zh,product_name_en,package_count,product_quantity," + + "pallet_count,gross_weight_kg,net_weight_kg,dimensional_weight_kg,volume_cbm,shipment_no,shipping_mark," + + "created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,1)", + id, PLAN, CUSTOMER, "真实验收客户", inboundId, detailId, orderId, MARKER + "-ORDER-" + inboundId, + inboundCode, 9001L, destination, "FBA " + destination, sku, "真实验收商品", "Real product", + packages, packages * 10, 1, 30.0, 27.0, 35.0, 0.4, MARKER + "-SHIP", MARKER + "-MARK"); + } + + private void seedLabel(long id, long inboundId, long orderId, long detailId, String inboundCode, + String suffix, int index, int total) { + jdbc.update("INSERT IGNORE INTO fl_inbound_carton_label(id,tenant_id,inbound_id,order_id,inbound_detail_id," + + "cargo_item_no,cargo_name,detail_package_count,inbound_code,box_code,total_carton_count,carton_index," + + "box_specification) VALUES(?,?,?,?,?,1,'真实验收商品',?,?,?,?,?, '验收箱规')", + id, CUSTOMER, inboundId, orderId, detailId, total, inboundCode, MARKER + "-" + suffix, total, index); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index ff1839740..039693cf0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -81,8 +81,8 @@ class SplitPreloadServiceTest { } @Test - void runningPdaAllocationMayReuseConfirmedPreload() { - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + void runningLoadingAllocationMayReuseConfirmedPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); PreloadPlanEntity plan = new PreloadPlanEntity(); plan.setStatus("CONFIRMED"); when(repository.findPlan(10, 20)).thenReturn(plan); @@ -93,8 +93,44 @@ class SplitPreloadServiceTest { } @Test - void runningPdaAllocationStillRejectsConfirmingPreload() { - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + void runningLoadingAllocationMayReusePendingPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setStatus("PENDING"); + when(repository.findPlan(10, 20)).thenReturn(plan); + + service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"); + + verify(repository, never()).createPlan(any()); + } + + @Test + void runningLoadingAllocationMustNotCreateMissingPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); + when(repository.findPlan(10, 20)).thenReturn(null); + + assertThatThrownBy(() -> service.ensureForRunningLoadingAllocation(10, 20, 7, "admin")) + .hasMessageContaining("缺少既有预装计划"); + + verify(repository, never()).createPlan(any()); + } + + @Test + void ordinaryLoadingAllocationStillRequiresWaitingSplit() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setStatus("PENDING"); + when(repository.findPlan(10, 20)).thenReturn(plan); + + assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin")) + .hasMessageContaining("仅待分柜订单"); + + verify(repository, never()).createPlan(any()); + } + + @Test + void runningLoadingAllocationStillRejectsConfirmingPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); PreloadPlanEntity plan = new PreloadPlanEntity(); plan.setStatus("CONFIRMING"); when(repository.findPlan(10, 20)).thenReturn(plan); @@ -102,5 +138,4 @@ class SplitPreloadServiceTest { assertThatThrownBy(() -> service.ensureForRunningLoadingAllocation(10, 20, 7, "admin")) .hasMessageContaining("已进入确认流程"); } - } diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json new file mode 100644 index 000000000..4ffb1a909 --- /dev/null +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -0,0 +1,95 @@ +{ + "schemaVersion": 1, + "description": "Development-stage Firstleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.", + "allowedSliceEdges": [ + "booking-customs-trucking->inbound", + "booking-customs-trucking->measurement-pricing", + "booking-customs-trucking->outbound", + "booking-customs-trucking->pricebook-query", + "booking-customs-trucking->shared-technical", + "booking-customs-trucking->split-loading", + "customer-statistics->shared-technical", + "finance-integration->inbound", + "finance-integration->measurement-pricing", + "finance-integration->shared-technical", + "finance-integration->split-loading", + "inbound->measurement-pricing", + "inbound->outbound", + "inbound->shared-technical", + "inbound->split-loading", + "measurement-pricing->booking-customs-trucking", + "measurement-pricing->finance-integration", + "measurement-pricing->inbound", + "measurement-pricing->pricebook-query", + "measurement-pricing->shared-technical", + "outbound->booking-customs-trucking", + "outbound->shared-technical", + "outbound->shipping-nodes", + "outbound->split-loading", + "shared-technical->booking-customs-trucking", + "shared-technical->finance-integration", + "shared-technical->inbound", + "shared-technical->measurement-pricing", + "shared-technical->split-loading", + "shipping-nodes->booking-customs-trucking", + "shipping-nodes->inbound", + "shipping-nodes->shared-technical", + "shipping-nodes->split-loading", + "split-loading->booking-customs-trucking", + "split-loading->inbound", + "split-loading->outbound", + "split-loading->pricebook-query", + "split-loading->shared-technical" + ], + "requiredClassLocations": { + "com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java", + "com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java", + "com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService": "src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java", + "com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos": "src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java", + "com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java", + "com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper": "src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java", + "com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService": "src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java", + "com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows": "src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java", + "com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase": "src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java", + "com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader": "src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java" + }, + "applicationDependencyRules": [ + { + "packagePrefix": "com.baiyue.firstleg.customerstatistics.application", + "allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus" + ] + }, + { + "packagePrefix": "com.baiyue.firstleg.pricebookquery.application", + "allowedInternalPrefix": "com.baiyue.firstleg.pricebookquery.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus" + ] + } + ], + "sliceExposureRules": [ + { + "slicePrefix": "com.baiyue.firstleg.pricebookquery.", + "allowedTargetPrefixes": [ + "com.baiyue.firstleg.pricebookquery.application.model.", + "com.baiyue.firstleg.pricebookquery.application.port.in." + ] + } + ], + "sliceSpecificRules": { + "customer-statistics": { + "allowedOutgoing": ["shared-technical"], + "allowedIncoming": [] + }, + "pricebook-query": { + "allowedOutgoing": [], + "allowedIncoming": ["booking-customs-trucking", "measurement-pricing", "split-loading"] + } + } +} diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md new file mode 100644 index 000000000..ddd4f2c28 --- /dev/null +++ b/gateway-overseas/AGENTS.md @@ -0,0 +1,35 @@ +# Overseas Gateway 模块指引 + +## 职责边界 + +- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。 +- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。 +- 修改海外 Token、Audience、JWKS、区域路由或服务身份时,先读 `../../docs/跨区域认证与服务通信契约.md`。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` | +| Audience 校验 | `config/AudienceJwtValidator.java` | +| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | +| PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed | +| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本落后立即拒绝 | +| 安全配置项 | `config/GatewaySecurityProperties.java` | +| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` | +| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | +| 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 显式覆盖商户仓库与平台 `warehouse-admin` 路径 | +| 海外 PDA 路由 | 设备端 `../../apps/pda-overseas-uniapp/`,服务端入口 `/api/pda/v1/last-leg/**`;必须在 Gateway 路由中显式声明并保持海外 Token/权限校验,不得让设备直连 Lastleg | +| 测试 | `src/test/java/com/baiyue/gateway/config/` | + +## 安全规则 + +- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。 +- 网关必须清除外部请求伪造的可信身份头,仅传递验签后生成的身份信息。 +- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。 +- merchant、platform 与 PDA 路径分别显式维护路由和授权边界;新增 PDA Controller 不会自动成为公网路由,Gateway 未声明前不得把设备链路标记为已联通。 + +## 修改与验证 + +- 定向验证:`mvn -pl gateway-overseas -am test`。 +- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。 diff --git a/gateway-overseas/pom.xml b/gateway-overseas/pom.xml new file mode 100644 index 000000000..01f656726 --- /dev/null +++ b/gateway-overseas/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + gateway-overseas + gateway-overseas + + + + org.springdoc + springdoc-openapi-starter-webflux-ui + + + com.baiyue.shared + baiyue-common-core + ${project.version} + + + org.springframework.cloud + spring-cloud-starter-gateway + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + org.springframework.bootspring-boot-maven-plugin + diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java b/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java new file mode 100644 index 000000000..8514ad509 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * API 网关启动入口,承载统一路由、认证校验和可信身份头转发。 + */ +@SpringBootApplication +public class BaiyueGatewayOverseasApplication { + public static void main(String[] args) { + SpringApplication.run(BaiyueGatewayOverseasApplication.class, args); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java new file mode 100644 index 000000000..5dae7304a --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java @@ -0,0 +1,32 @@ +package com.baiyue.gateway.config; + +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.web.server.authorization.AuthorizationContext; +import reactor.core.publisher.Mono; + +/** + * 网关应用范围授权器,根据 JWT 的 {@code app_code} 声明隔离商户端与平台端接口。 + */ +final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager { + private final String expectedAppCode; + + AppScopeAuthorizationManager(String expectedAppCode) { + this.expectedAppCode = expectedAppCode; + } + + @Override + public Mono check(Mono authentication, + AuthorizationContext context) { + return authentication.filter(Authentication::isAuthenticated) + .map(this::hasExpectedScope).map(AuthorizationDecision::new) + .defaultIfEmpty(new AuthorizationDecision(false)); + } + + private boolean hasExpectedScope(Authentication authentication) { + return authentication instanceof JwtAuthenticationToken jwt + && expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code")); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java new file mode 100644 index 000000000..0305a7498 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java @@ -0,0 +1,23 @@ +package com.baiyue.gateway.config; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +final class AudienceJwtValidator implements OAuth2TokenValidator { + private final String requiredAudience; + + AudienceJwtValidator(String requiredAudience) { + this.requiredAudience = requiredAudience; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + if (jwt.getAudience().contains(requiredAudience)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure(new OAuth2Error( + "invalid_token", "JWT audience 不允许访问海外 API", null)); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java new file mode 100644 index 000000000..a23d519e5 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -0,0 +1,90 @@ +package com.baiyue.gateway.config; + +import com.baiyue.gateway.filter.CorrelationIdWebFilter; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.web.server.SecurityWebFilterChain; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。 + */ +@Configuration +@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class, + OverseasSecurityProjectionProperties.class}) +public class GatewaySecurityConfig { + @Bean + SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, + GatewaySecurityProperties properties, + ObjectMapper objectMapper) { + var exchanges = http.authorizeExchange(); + properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll()); + exchanges.pathMatchers("/api/merchant/**") + .access(new AppScopeAuthorizationManager("MERCHANT")); + exchanges.pathMatchers("/api/platform/**") + .access(new AppScopeAuthorizationManager("PLATFORM")); + exchanges.anyExchange().authenticated(); + return http.csrf(ServerHttpSecurity.CsrfSpec::disable) + .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) + .formLogin(ServerHttpSecurity.FormLoginSpec::disable) + .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { }) + .authenticationEntryPoint((exchange, exception) -> writeError(exchange, objectMapper, + HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效"))) + .exceptionHandling(errors -> errors.accessDeniedHandler((exchange, exception) -> + writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源"))) + .build(); + } + + @Bean + ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) { + NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder + .withJwkSetUri(properties.jwkSetUri()) + .jwsAlgorithms(algorithms -> { + algorithms.add(SignatureAlgorithm.RS256); + }) + .build(); + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(properties.issuer()), + new AudienceJwtValidator(properties.audience()), + new RequiredClaimsJwtValidator())); + return decoder; + } + + public static Mono writeError(org.springframework.web.server.ServerWebExchange exchange, + ObjectMapper mapper, HttpStatus status, String code, String message) { + exchange.getResponse().setStatusCode(status); + exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + String requestId = CorrelationIdWebFilter.ensureRequestId(exchange); + String traceId = CorrelationIdWebFilter.ensureTraceId(exchange); + exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.REQUEST_HEADER, requestId); + exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.TRACE_HEADER, traceId); + Map body = new LinkedHashMap<>(); + body.put("code", code); + body.put("message", message); + body.put("data", null); + body.put("requestId", requestId); + body.put("traceId", traceId); + body.put("timestamp", Instant.now().toString()); + try { + byte[] bytes = mapper.writeValueAsBytes(body); + return exchange.getResponse().writeWith( + Mono.just(exchange.getResponse().bufferFactory().wrap(bytes))); + } catch (Exception exception) { + return exchange.getResponse().setComplete(); + } + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java new file mode 100644 index 000000000..3dab56ba2 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java @@ -0,0 +1,27 @@ +package com.baiyue.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +/** + * 海外网关安全配置,只保存验签所需的签发方和公钥集地址。 + */ +@ConfigurationProperties(prefix = "baiyue.security") +public record GatewaySecurityProperties(String jwkSetUri, + String issuer, + String audience, + List permitPaths) { + public GatewaySecurityProperties { + if (jwkSetUri == null || jwkSetUri.isBlank()) { + throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI"); + } + if (issuer == null || issuer.isBlank()) { + throw new IllegalArgumentException("必须配置 JWT_ISSUER"); + } + if (audience == null || audience.isBlank()) { + throw new IllegalArgumentException("必须配置 JWT_AUDIENCE"); + } + permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java new file mode 100644 index 000000000..e488beaa9 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java @@ -0,0 +1,17 @@ +package com.baiyue.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "baiyue.security.overseas-projection") +public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) { + public OverseasSecurityProjectionProperties { + if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 LASTLEG_SECURITY_SERVICE_URL"); + if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { + throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + timeout = timeout == null || timeout.isNegative() || timeout.isZero() ? Duration.ofSeconds(2) : timeout; + serviceUrl = serviceUrl.replaceAll("/+$", ""); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java new file mode 100644 index 000000000..e2e40b47e --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java @@ -0,0 +1,25 @@ +package com.baiyue.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** 海外 PDA 实时会话校验配置;URL 与服务令牌只能由环境提供。 */ +@ConfigurationProperties(prefix = "baiyue.security.pda-session-validation") +public record PdaSessionValidationProperties(String authUrl, + String internalServiceToken, + Duration timeout) { + public PdaSessionValidationProperties { + if (authUrl == null || authUrl.isBlank()) { + throw new IllegalArgumentException("必须配置 AUTH_SESSION_VALIDATION_URL"); + } + if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { + throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + timeout = timeout == null ? Duration.ofSeconds(2) : timeout; + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("PDA_SESSION_VALIDATION_TIMEOUT 必须大于 0"); + } + authUrl = authUrl.replaceAll("/+$", ""); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java new file mode 100644 index 000000000..35ff9d210 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java @@ -0,0 +1,68 @@ +package com.baiyue.gateway.config; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +/** Validates the identity and authorization claims required by overseas APIs. */ +final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { + private static final List REQUIRED_CLAIMS = List.of( + "sub", "tenant_id", "app_code", "token_version", "permission_version", "authorities"); + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + for (String claim : REQUIRED_CLAIMS) { + Object value = jwt.getClaims().get(claim); + if (value == null || value.toString().isBlank()) { + return OAuth2TokenValidatorResult.failure(new OAuth2Error( + "invalid_token", "JWT 缺少必要声明: " + claim, null)); + } + } + Long subject = longValue(jwt.getSubject()); + Long tenantId = longValue(jwt.getClaims().get("tenant_id")); + Long permissionVersion = longValue(jwt.getClaims().get("permission_version")); + Long tokenVersion = longValue(jwt.getClaims().get("token_version")); + String appCode = String.valueOf(jwt.getClaims().get("app_code")).toUpperCase(Locale.ROOT); + if (subject == null || subject <= 0) { + return failure("JWT sub 必须是正整数用户 ID"); + } + if (tenantId == null || tenantId < 0) { + return failure("JWT tenant_id 必须是非负整数客户范围"); + } + if (permissionVersion == null || permissionVersion < 0) { + return failure("JWT permission_version 必须是非负整数"); + } + if (tokenVersion == null || tokenVersion < 0) { + return failure("JWT token_version 必须是非负整数"); + } + if (!("PLATFORM".equals(appCode) && tenantId == 0L) + && !("MERCHANT".equals(appCode) && tenantId > 0L)) { + return failure("JWT app_code 与 tenant_id 访问域不一致"); + } + Object authorities = jwt.getClaims().get("authorities"); + if (!(authorities instanceof Collection values) + || values.stream().anyMatch(value -> value == null || value.toString().isBlank())) { + return failure("JWT authorities 必须是权限列表"); + } + return OAuth2TokenValidatorResult.success(); + } + + private static Long longValue(Object value) { + if (value instanceof Number number) return number.longValue(); + if (value == null) return null; + try { + return Long.valueOf(value.toString()); + } catch (NumberFormatException ignored) { + return null; + } + } + + private static OAuth2TokenValidatorResult failure(String message) { + return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", message, null)); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java new file mode 100644 index 000000000..f25941cec --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java @@ -0,0 +1,52 @@ +package com.baiyue.gateway.filter; + +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +/** Ensures overseas security failures carry the same correlation contract as normal API responses. */ +@Component +public class CorrelationIdWebFilter implements WebFilter, Ordered { + public static final String REQUEST_HEADER = "X-Request-Id"; + public static final String TRACE_HEADER = "X-Trace-Id"; + private static final String REQUEST_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".requestId"; + private static final String TRACE_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".traceId"; + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + exchange.getResponse().getHeaders().set(REQUEST_HEADER, ensureRequestId(exchange)); + exchange.getResponse().getHeaders().set(TRACE_HEADER, ensureTraceId(exchange)); + return chain.filter(exchange); + } + + public static String ensureRequestId(ServerWebExchange exchange) { + return (String) exchange.getAttributes().computeIfAbsent(REQUEST_ATTRIBUTE, + ignored -> accepted(exchange.getRequest().getHeaders(), REQUEST_HEADER)); + } + + public static String ensureTraceId(ServerWebExchange exchange) { + return (String) exchange.getAttributes().computeIfAbsent(TRACE_ATTRIBUTE, + ignored -> accepted(exchange.getRequest().getHeaders(), TRACE_HEADER)); + } + + private static String accepted(HttpHeaders headers, String name) { + String incoming = headers.getFirst(name); + return valid(incoming) ? incoming : UUID.randomUUID().toString(); + } + + private static boolean valid(String value) { + return value != null && !value.isBlank() && value.length() >= 16 && value.length() <= 128 + && value.chars().allMatch(character -> character >= 0x21 && character <= 0x7e); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java new file mode 100644 index 000000000..17f3af48b --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java @@ -0,0 +1,37 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient { + private static final String PATH = "/internal/lastleg/security/v1/validate"; + private final WebClient client; + private final OverseasSecurityProjectionProperties properties; + + public HttpOverseasSecurityValidationClient(WebClient.Builder builder, OverseasSecurityProjectionProperties properties) { + this.client = builder.build(); + this.properties = properties; + } + + @Override + public Mono validate(long userId, long tenantId, long permissionVersion) { + return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON) + .header("X-Internal-Service-Token", properties.internalServiceToken()) + .bodyValue(new Request(userId, tenantId, permissionVersion)) + .retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> { + JsonNode data = body == null ? null : body.path("data"); + if (data == null || data.isMissingNode() || !data.has("valid")) { + return Mono.error(new IllegalStateException("海外安全投影响应无效")); + } + return Mono.just(new Result(data.path("valid").asBoolean(false), + data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null))); + }); + } + + private record Request(long userId, long tenantId, long permissionVersion) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java new file mode 100644 index 000000000..7f9bd9a97 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java @@ -0,0 +1,46 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.PdaSessionValidationProperties; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +/** 调用 Auth 内部实时校验端点;任何传输或响应异常均交由 Gateway fail-closed。 */ +@Component +public class HttpPdaSessionValidationClient implements PdaSessionValidationClient { + private static final String PATH = "/internal/auth/accounts/overseas-session/validate"; + private final WebClient webClient; + private final PdaSessionValidationProperties properties; + + public HttpPdaSessionValidationClient(WebClient.Builder builder, PdaSessionValidationProperties properties) { + this.webClient = builder.build(); + this.properties = properties; + } + + @Override + public Mono validate(long userId, int tokenVersion, long overseasWarehouseId) { + return webClient.post() + .uri(properties.authUrl() + PATH) + .contentType(MediaType.APPLICATION_JSON) + .header("X-Internal-Service-Token", properties.internalServiceToken()) + .bodyValue(new Request(userId, tokenVersion, overseasWarehouseId)) + .retrieve() + .bodyToMono(JsonNode.class) + .timeout(properties.timeout()) + .flatMap(this::decode); + } + + private Mono decode(JsonNode envelope) { + JsonNode data = envelope == null ? null : envelope.path("data"); + if (data == null || data.isMissingNode() || data.isNull() || !data.has("valid")) { + return Mono.error(new IllegalStateException("Auth overseas session validation response is invalid")); + } + boolean valid = data.path("valid").asBoolean(false); + String reason = data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null); + return Mono.just(new Result(valid, reason)); + } + + private record Request(long userId, int tokenVersion, long overseasWarehouseId) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java new file mode 100644 index 000000000..91fc516cb --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java @@ -0,0 +1,73 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.GatewaySecurityConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { + private final OverseasSecurityValidationClient client; + private final ObjectMapper objectMapper; + + public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper) { + this.client = client; + this.objectMapper = objectMapper; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + if (!exchange.getRequest().getURI().getPath().startsWith("/api/")) return chain.filter(exchange); + return exchange.getPrincipal().cast(Authentication.class).filter(Authentication::isAuthenticated) + .ofType(JwtAuthenticationToken.class) + .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "未登录或令牌已失效"))) + .flatMap(authentication -> validate(exchange, chain, authentication)) + .onErrorResume(SecurityProjectionFailure.class, + failure -> write(exchange, failure.code, failure.getMessage())) + .onErrorResume(failure -> write(exchange, "A0401", "海外安全状态校验不可用")); + } + + private Mono validate(ServerWebExchange exchange, GatewayFilterChain chain, + JwtAuthenticationToken authentication) { + Long userId = number(authentication.getToken().getSubject()); + Long tenantId = number(authentication.getToken().getClaim("tenant_id")); + Long permissionVersion = number(authentication.getToken().getClaim("permission_version")); + if (userId == null || tenantId == null || permissionVersion == null) { + return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失")); + } + return client.validate(userId, tenantId, permissionVersion) + .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用"))) + .flatMap(result -> { + if (result.valid()) return chain.filter(exchange); + if ("USER_DISABLED".equals(result.failureReason())) { + return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用")); + } + return Mono.error(new SecurityProjectionFailure("A0403", "客户或权限状态已变化")); + }); + } + + private Mono write(ServerWebExchange exchange, String code, String message) { + return GatewaySecurityConfig.writeError(exchange, objectMapper, + "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED, code, message); + } + + 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; } + } + + @Override public int getOrder() { return -95; } + + private static final class SecurityProjectionFailure extends RuntimeException { + private final String code; + private SecurityProjectionFailure(String code, String message) { super(message); this.code = code; } + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java new file mode 100644 index 000000000..a267cb233 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java @@ -0,0 +1,8 @@ +package com.baiyue.gateway.filter; + +import reactor.core.publisher.Mono; + +public interface OverseasSecurityValidationClient { + Mono validate(long userId, long tenantId, long permissionVersion); + record Result(boolean valid, String failureReason) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java new file mode 100644 index 000000000..b40726567 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java @@ -0,0 +1,10 @@ +package com.baiyue.gateway.filter; + +import reactor.core.publisher.Mono; + +/** 只传递本地验签后提取的稳定 claims,不传递 raw JWT。 */ +public interface PdaSessionValidationClient { + Mono validate(long userId, int tokenVersion, long overseasWarehouseId); + + record Result(boolean valid, String failureReason) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java new file mode 100644 index 000000000..4e94ba57b --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java @@ -0,0 +1,92 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.GatewaySecurityConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** 仅对海外 PDA API 执行无缓存的账号版本与仓绑定实时校验。 */ +@Component +public class PdaSessionValidationFilter implements GlobalFilter, Ordered { + private static final String PDA_PREFIX = "/api/pda/"; + private final PdaSessionValidationClient client; + private final ObjectMapper objectMapper; + + public PdaSessionValidationFilter(PdaSessionValidationClient client, ObjectMapper objectMapper) { + this.client = client; + this.objectMapper = objectMapper; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + if (!exchange.getRequest().getURI().getPath().startsWith(PDA_PREFIX)) { + return chain.filter(exchange); + } + return exchange.getPrincipal().cast(Authentication.class) + .filter(Authentication::isAuthenticated) + .ofType(JwtAuthenticationToken.class) + .switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "未登录或令牌已失效"))) + .flatMap(authentication -> validate(exchange, chain, authentication)) + .onErrorResume(InvalidPdaSession.class, failure -> write(exchange, failure.code, failure.getMessage())) + .onErrorResume(failure -> write(exchange, "A0401", "海外PDA会话校验不可用")); + } + + private Mono validate(ServerWebExchange exchange, GatewayFilterChain chain, + JwtAuthenticationToken authentication) { + Long userId = longValue(authentication.getToken().getSubject()); + Integer tokenVersion = intClaim(authentication, "token_version"); + Long warehouseId = longClaim(authentication, "overseas_warehouse_id"); + if (userId == null || tokenVersion == null) { + return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌版本声明缺失")); + } + if (warehouseId == null) { + return Mono.error(new InvalidPdaSession("A0403", "海外PDA账号未绑定仓库")); + } + return client.validate(userId, tokenVersion, warehouseId) + .switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "海外PDA会话校验不可用"))) + .flatMap(result -> { + if (result.valid()) return chain.filter(exchange); + if ("WAREHOUSE_UNBOUND".equals(result.failureReason()) + || "WAREHOUSE_MISMATCH".equals(result.failureReason())) { + return Mono.error(new InvalidPdaSession("A0403", "海外PDA仓库绑定已变化")); + } + return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌已失效")); + }); + } + + private Mono write(ServerWebExchange exchange, String code, String message) { + HttpStatus status = "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED; + return GatewaySecurityConfig.writeError(exchange, objectMapper, status, code, message); + } + + private static Integer intClaim(JwtAuthenticationToken authentication, String name) { + Object value = authentication.getToken().getClaims().get(name); + if (value instanceof Number number) return number.intValue(); + try { return value == null ? null : Integer.valueOf(value.toString()); } + catch (NumberFormatException ignored) { return null; } + } + + private static Long longClaim(JwtAuthenticationToken authentication, String name) { + return longValue(authentication.getToken().getClaims().get(name)); + } + + private static Long longValue(Object value) { + if (value instanceof Number number) return number.longValue(); + try { return value == null ? null : Long.valueOf(value.toString()); } + catch (NumberFormatException ignored) { return null; } + } + + @Override public int getOrder() { return -90; } + + private static final class InvalidPdaSession extends RuntimeException { + private final String code; + private InvalidPdaSession(String code, String message) { super(message); this.code = code; } + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java new file mode 100644 index 000000000..80b2b320b --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java @@ -0,0 +1,73 @@ +package com.baiyue.gateway.filter; + +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.List; + +/** + * 网关可信身份头过滤器,清除外部伪造身份头,并从已认证 JWT 注入用户、租户和应用范围信息。 + */ +@Component +public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered { + private static final List TRUSTED_HEADERS = List.of( + "X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id", + "X-App-Code", "X-Client-Type" + ); + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + return exchange.getPrincipal() + .cast(Authentication.class) + .defaultIfEmpty(new AnonymousAuthentication()) + .flatMap(authentication -> { + var requestBuilder = exchange.getRequest().mutate(); + requestBuilder.headers(headers -> TRUSTED_HEADERS.forEach(headers::remove)); + + if (authentication instanceof JwtAuthenticationToken jwt && authentication.isAuthenticated()) { + put(requestBuilder, "X-User-Id", jwt.getToken().getSubject()); + put(requestBuilder, "X-Membership-Id", claim(jwt, "membership_id")); + put(requestBuilder, "X-Tenant-Id", claim(jwt, "tenant_id")); + put(requestBuilder, "X-Operator-Tenant-Id", claim(jwt, "operator_tenant_id")); + put(requestBuilder, "X-App-Code", claim(jwt, "app_code")); + put(requestBuilder, "X-Client-Type", claim(jwt, "client_type")); + } + return chain.filter(exchange.mutate().request(requestBuilder.build()).build()); + }); + } + + private static String claim(JwtAuthenticationToken jwt, String name) { + Object value = jwt.getToken().getClaims().get(name); + return value == null ? null : value.toString(); + } + + private static void put(org.springframework.http.server.reactive.ServerHttpRequest.Builder builder, + String name, + String value) { + if (value != null && !value.isBlank()) { + builder.header(name, value); + } + } + + @Override + public int getOrder() { + return -100; + } + + private static final class AnonymousAuthentication implements Authentication { + @Override public java.util.Collection getAuthorities() { return List.of(); } + @Override public Object getCredentials() { return ""; } + @Override public Object getDetails() { return null; } + @Override public Object getPrincipal() { return "anonymous"; } + @Override public boolean isAuthenticated() { return false; } + @Override public void setAuthenticated(boolean isAuthenticated) { } + @Override public String getName() { return "anonymous"; } + } +} + diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml new file mode 100644 index 000000000..bdcda68d8 --- /dev/null +++ b/gateway-overseas/src/main/resources/application-overseas.yml @@ -0,0 +1,72 @@ +spring: + config: + activate: + on-profile: overseas + cloud: + gateway: + discovery: + locator: + enabled: false + routes: + - id: lastleg-service + uri: lb://lastleg-service + predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/**"] + - id: warehouse-overseas-service + uri: lb://warehouse-overseas-service + predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"] + - id: file-service + uri: lb://file-service + predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"] + - id: print-service + uri: lb://print-service + predicates: ["Path=/api/merchant/v1/print/**,/api/platform/v1/print/**"] + - id: lastleg-openapi + uri: lb://lastleg-service + predicates: [Path=/lastleg-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: warehouse-openapi + uri: lb://warehouse-overseas-service + predicates: [Path=/warehouse-overseas-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: file-openapi + uri: lb://file-service + predicates: [Path=/file-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: print-openapi + uri: lb://print-service + predicates: [Path=/print-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: audit-openapi + uri: lb://audit-service + predicates: [Path=/audit-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: integration-openapi + uri: lb://integration-service + predicates: [Path=/integration-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: event-bridge-openapi + uri: lb://event-bridge-service + predicates: [Path=/event-bridge-service/v3/api-docs/**] + filters: [StripPrefix=1] + +baiyue: + deployment: + region-code: ${REGION_CODE:OVERSEAS} + cell: ${DEPLOYMENT_CELL:prod-overseas} + data-residency: ${DATA_RESIDENCY:OVERSEAS} + +springdoc: + swagger-ui: + urls: + - { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant } + - { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform } + - { name: lastleg-pda, url: /lastleg-service/v3/api-docs/pda } + - { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant } + - { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform } + - { name: file-platform, url: /file-service/v3/api-docs/platform } + - { name: file-merchant, url: /file-service/v3/api-docs/merchant } + - { name: print-platform, url: /print-service/v3/api-docs/platform } + - { name: print-merchant, url: /print-service/v3/api-docs/merchant } + - { name: audit, url: /audit-service/v3/api-docs } + - { name: integration, url: /integration-service/v3/api-docs } + - { name: event-bridge, url: /event-bridge-service/v3/api-docs } diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml new file mode 100644 index 000000000..d61d0ec19 --- /dev/null +++ b/gateway-overseas/src/main/resources/application.yml @@ -0,0 +1,78 @@ +server: + port: ${GATEWAY_PORT:9300} + +spring: + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + application: + name: gateway-overseas + profiles: + default: overseas + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + config: + import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + cloud: + nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + enabled: ${NACOS_DISCOVERY_ENABLED:true} + register-enabled: ${NACOS_REGISTER_ENABLED:true} + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:public} + config: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + enabled: ${NACOS_CONFIG_ENABLED:true} + namespace: ${NACOS_NAMESPACE:public} + gateway: + discovery: + locator: + enabled: false + +baiyue: + security: + issuer: ${JWT_ISSUER:https://auth.example.com} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json} + pda-session-validation: + auth-url: ${AUTH_SESSION_VALIDATION_URL} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} + overseas-projection: + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} + permit-paths: + - /actuator/health + - /swagger-ui/** + - /webjars/** + - /v3/api-docs/** + - /lastleg-service/v3/api-docs/** + - /warehouse-overseas-service/v3/api-docs/** + - /file-service/v3/api-docs/** + - /print-service/v3/api-docs/** + - /audit-service/v3/api-docs/** + - /integration-service/v3/api-docs/** + - /event-bridge-service/v3/api-docs/** + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes: + enabled: true + +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java new file mode 100644 index 000000000..21e2371a7 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java @@ -0,0 +1,55 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; + +class AppScopeAuthorizationManagerTest { + @Test + void acceptsMatchingApplicationScope() { + var manager = new AppScopeAuthorizationManager("MERCHANT"); + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) + .expectNextMatches(decision -> decision.isGranted()) + .verifyComplete(); + } + + @Test + void rejectsDifferentApplicationScope() { + var manager = new AppScopeAuthorizationManager("PLATFORM"); + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) + .expectNextMatches(decision -> !decision.isGranted()) + .verifyComplete(); + } + + @Test + void rejectsMissingApplicationScope() { + var manager = new AppScopeAuthorizationManager("PLATFORM"); + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("1") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .build(); + StepVerifier.create(manager.check(Mono.just(new JwtAuthenticationToken( + jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated")))), null)) + .expectNextMatches(decision -> !decision.isGranted()) + .verifyComplete(); + } + + private JwtAuthenticationToken authentication(String appCode) { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("1") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .claim("app_code", appCode) + .build(); + return new JwtAuthenticationToken(jwt, + java.util.List.of(new SimpleGrantedAuthority("authenticated"))); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java new file mode 100644 index 000000000..474c2ec6f --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java @@ -0,0 +1,33 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AudienceJwtValidatorTest { + private final AudienceJwtValidator validator = new AudienceJwtValidator("lastleg-api"); + + @Test + void acceptsTokenForLastlegApi() { + assertThat(validator.validate(jwt(List.of("cn-api", "lastleg-api"))).hasErrors()).isFalse(); + } + + @Test + void rejectsTokenForAnotherApi() { + assertThat(validator.validate(jwt(List.of("cn-api"))).hasErrors()).isTrue(); + } + + private Jwt jwt(List audiences) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("1001") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .audience(audiences) + .build(); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java new file mode 100644 index 000000000..6b1cea992 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.gateway.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class GatewaySecurityErrorContractTest { + @Test + void audienceAndAuthenticationFailuresExposeRequestAndTraceCorrelation() throws Exception { + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/v1/test") + .header("X-Request-Id", "request-1234567890") + .header("X-Trace-Id", "trace-123456789012") + .build()); + + GatewaySecurityConfig.writeError(exchange, new ObjectMapper(), HttpStatus.UNAUTHORIZED, + "A0401", "未登录或令牌已失效").block(); + + var body = new ObjectMapper().readTree(exchange.getResponse().getBodyAsString().block()); + assertEquals("request-1234567890", body.path("requestId").asText()); + assertEquals("trace-123456789012", body.path("traceId").asText()); + assertEquals(body.path("requestId").asText(), exchange.getResponse().getHeaders().getFirst("X-Request-Id")); + assertEquals(body.path("traceId").asText(), exchange.getResponse().getHeaders().getFirst("X-Trace-Id")); + assertFalse(body.path("timestamp").asText().isBlank()); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java new file mode 100644 index 000000000..345210960 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasLastlegRouteContractTest { + @Test + void browserAndPdaPathsAreForwardedToLastlegWithoutPrefixRewrite() throws IOException { + try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { + assertThat(input).isNotNull(); + String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + int routeStart = yaml.indexOf("- id: lastleg-service"); + int routeEnd = yaml.indexOf("- id: warehouse-overseas-service", routeStart); + assertThat(routeStart).isGreaterThanOrEqualTo(0); + assertThat(routeEnd).isGreaterThan(routeStart); + String route = yaml.substring(routeStart, routeEnd); + + assertThat(route).contains("uri: lb://lastleg-service") + .contains("/api/platform/v1/last-leg/**") + .contains("/api/merchant/v1/last-leg/**") + .contains("/api/pda/v1/last-leg/**") + .doesNotContain("StripPrefix", "RewritePath"); + } + } + + @Test + void swaggerPublishesAnIndependentLastlegPdaGroup() throws IOException { + try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { + assertThat(input).isNotNull(); + String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + assertThat(yaml).contains("name: lastleg-pda", "url: /lastleg-service/v3/api-docs/pda") + .contains("Path=/lastleg-service/v3/api-docs/**") + .doesNotContain("event-bridge-service/v3/api-docs/pda"); + } + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java new file mode 100644 index 000000000..45bd73e1d --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java @@ -0,0 +1,42 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasSecurityValidationDeploymentContractTest { + @Test + void gatewayAndEventBridgeUseLastlegSecurityEndpointsWithoutTheRetiredService() throws Exception { + String client = read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java"); + String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml"); + String gatewayNacos = read("config/nacos/overseas/gateway-overseas.yml"); + String bridgeApplication = read("event-bridge-service/src/main/resources/application.yml"); + String bridgeNacos = read("config/nacos/overseas/event-bridge-service.yml"); + String compose = read("deploy/docker-compose.overseas.yml"); + + assertThat(client).contains("/internal/lastleg/security/v1/validate") + .contains("new Request(userId, tenantId, permissionVersion)") + .doesNotContain("new Request(userId, tenantId, tokenVersion") + .doesNotContain("/internal/overseas-security/"); + for (String source : new String[]{gatewayApplication, gatewayNacos, compose}) { + assertThat(source).contains("LASTLEG_SECURITY_SERVICE_URL", "http://lastleg-service:9303"); + } + for (String source : new String[]{bridgeApplication, bridgeNacos, compose}) { + assertThat(source).contains("LASTLEG_SECURITY_EVENT_CONSUMER_URL", + "/internal/lastleg/security/v1/events"); + } + assertThat(gatewayApplication + gatewayNacos + bridgeApplication + bridgeNacos + compose) + .doesNotContain("overseas-security-service", "9307", "OVERSEAS_SECURITY_DB_URL", + "OVERSEAS_SECURITY_SERVICE_URL", "OVERSEAS_SECURITY_EVENT_CONSUMER_URL"); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path)) path = Path.of("..").resolve(relative); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java new file mode 100644 index 000000000..2b67d9ffd --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java @@ -0,0 +1,43 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaSessionValidationDeploymentContractTest { + @Test + void nacosComposeAndJenkinsDeploymentRequireOnlyEnvironmentBackedAuthCoordinates() throws Exception { + String application = read("gateway-overseas/src/main/resources/application.yml"); + String nacos = read("config/nacos/overseas/baiyue-common.yml"); + String compose = read("deploy/docker-compose.overseas.yml"); + String deployServices = read("deploy/scripts/deploy-services.sh"); + String deployBuiltService = read("deploy/scripts/deploy-built-service.sh"); + String jenkins = read("Jenkinsfile"); + + for (String source : new String[]{application, nacos, compose}) { + assertThat(source).contains("AUTH_SESSION_VALIDATION_URL", "INTERNAL_SERVICE_TOKEN", + "PDA_SESSION_VALIDATION_TIMEOUT"); + } + assertThat(deployServices).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL"); + assertThat(deployBuiltService).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL"); + assertThat(jenkins).contains("'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'", "'auth': 'SERVICE_AUTH'", + "'iam': 'SERVICE_IAM'"); + assertThat(application).doesNotContain("/internal/auth/accounts/overseas-session/validate"); + assertThat(read("gateway-overseas/src/main/resources/application-overseas.yml")) + .doesNotContain("/internal/auth/**", "/internal/auth/accounts/overseas-session/validate"); + assertThat(read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java")) + .contains("new Request(userId, tokenVersion, overseasWarehouseId)", + ".header(\"X-Internal-Service-Token\"") + .doesNotContain("Authorization", "accessToken", "rawToken", "getTokenValue"); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path)) path = Path.of("..").resolve(relative); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java new file mode 100644 index 000000000..fa9c07ea8 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java @@ -0,0 +1,94 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class RequiredClaimsJwtValidatorTest { + private final RequiredClaimsJwtValidator validator = new RequiredClaimsJwtValidator(); + + @Test + void acceptsCompleteIdentityClaims() { + assertThat(validator.validate(jwt(true)).hasErrors()).isFalse(); + } + + @Test + void rejectsMissingPermissionVersion() { + assertThat(validator.validate(jwt(false)).hasErrors()).isTrue(); + } + + @Test + void rejectsMissingAccountTokenVersion() { + Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", "0").claim("app_code", "PLATFORM") + .claim("permission_version", 3L).claim("authorities", List.of()) + .build(); + assertThat(validator.validate(value).hasErrors()).isTrue(); + } + + @Test + void rejectsMissingAuthorities() { + Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1) + .claim("permission_version", 3L) + .build(); + assertThat(validator.validate(value).hasErrors()).isTrue(); + } + + @Test + void acceptsEmptyAuthoritySnapshotAndLeavesAuthorizationToControllers() { + assertThat(validator.validate(jwt("0", "PLATFORM", List.of(), true)).hasErrors()).isFalse(); + } + + @Test + void acceptsMerchantIdentityOnlyWithPositiveCustomerScope() { + assertThat(validator.validate(jwt("2001", "MERCHANT", List.of("merchant:order:list"), true)).hasErrors()) + .isFalse(); + } + + @Test + void rejectsApplicationAndCustomerScopeMismatch() { + assertThat(validator.validate(jwt("2001", "PLATFORM", List.of("platform:test:list"), true)).hasErrors()) + .isTrue(); + assertThat(validator.validate(jwt("0", "MERCHANT", List.of("merchant:test:list"), true)).hasErrors()) + .isTrue(); + } + + @Test + void rejectsMalformedIdentityAndPermissionVersion() { + Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L) + .claim("token_version", 1) + .claim("authorities", List.of()).build(); + assertThat(validator.validate(value).hasErrors()).isTrue(); + } + + private Jwt jwt(boolean includePermissionVersion) { + return jwt("0", "PLATFORM", + List.of("platform:overseas-warehouse:inventory:list"), includePermissionVersion); + } + + private Jwt jwt(String tenantId, String appCode, List authorities, + boolean includePermissionVersion) { + Jwt.Builder builder = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("1001") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", tenantId) + .claim("app_code", appCode) + .claim("token_version", 1) + .claim("authorities", authorities); + if (includePermissionVersion) { + builder.claim("permission_version", 3L); + } + return builder.build(); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java new file mode 100644 index 000000000..1857158e8 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java @@ -0,0 +1,83 @@ +package com.baiyue.gateway.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class OverseasSecurityProjectionFilterTest { + private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class); + private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper()); + + @Test void disabledUserFailsWithA0401() { + when(client.validate(42, 0, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(false, "USER_DISABLED"))); + var exchange = exchange("/api/platform/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(body(exchange)).contains("A0401"); + } + + @Test void disabledTenantAndStalePermissionFailWithA0403() { + for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) { + when(client.validate(42, 0, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(false, reason))); + var exchange = exchange("/api/merchant/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(body(exchange)).contains("A0403"); + } + } + + @Test void projectionUnavailableFailsClosed() { + when(client.validate(42, 0, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); + var exchange = exchange("/api/platform/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() { + when(client.validate(42, 0, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(true, null))); + AtomicBoolean routed = new AtomicBoolean(); + StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> { + routed.set(true); return Mono.empty(); + })).verifyComplete(); + assertThat(routed).isTrue(); + + var isolatedClient = mock(OverseasSecurityValidationClient.class); + var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper()); + StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete(); + verifyNoInteractions(isolatedClient); + } + + private static ServerWebExchange exchange(String path) { + Jwt jwt = Jwt.withTokenValue("verified").header("alg", "RS256").subject("42") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", 0L).claim("token_version", 3L).claim("permission_version", 8L).build(); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(path).build()); + return exchange.mutate().principal(Mono.just(new JwtAuthenticationToken(jwt, + List.of(new SimpleGrantedAuthority("ROLE_PLATFORM_USER"))))).build(); + } + + private static String body(ServerWebExchange exchange) { + return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse()) + .getBodyAsString().block(); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java new file mode 100644 index 000000000..ae9d3aa24 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java @@ -0,0 +1,119 @@ +package com.baiyue.gateway.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class PdaSessionValidationFilterTest { + private final PdaSessionValidationClient client = mock(PdaSessionValidationClient.class); + private final PdaSessionValidationFilter filter = new PdaSessionValidationFilter(client, new ObjectMapper()); + + @Test + void oldTokenIsRejectedImmediatelyAfterWarehouseChange() { + when(client.validate(42L, 7, 900L)).thenReturn(Mono.just( + new PdaSessionValidationClient.Result(false, "TOKEN_VERSION_MISMATCH"))); + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(7, 900L)); + + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(body(exchange)).contains("A0401", "令牌已失效"); + } + + @Test + void newlyIssuedTokenWithCurrentWarehousePasses() { + when(client.validate(42L, 8, 901L)).thenReturn(Mono.just( + new PdaSessionValidationClient.Result(true, null))); + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L)); + AtomicBoolean routed = new AtomicBoolean(); + + StepVerifier.create(filter.filter(exchange, ignored -> { + routed.set(true); + return Mono.empty(); + })).verifyComplete(); + + assertThat(routed).isTrue(); + assertThat(exchange.getResponse().getStatusCode()).isNull(); + } + + @Test + void unboundWarehouseIsDeniedWithoutCallingAuth() { + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, null)); + + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(body(exchange)).contains("A0403", "未绑定仓库"); + verifyNoInteractions(client); + } + + @Test + void authUnavailableFailsClosed() { + when(client.validate(42L, 8, 901L)).thenReturn(Mono.error(new IllegalStateException("timeout"))); + var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L)); + + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(body(exchange)).contains("A0401", "校验不可用"); + } + + @Test + void ordinaryOverseasAdminAndClientPathsDoNotSynchronouslyValidate() { + for (String path : new String[]{"/api/platform/v1/last-leg/orders", "/api/merchant/v1/last-leg/orders"}) { + var exchange = exchange(path, claims(7, 900L)); + AtomicBoolean routed = new AtomicBoolean(); + StepVerifier.create(filter.filter(exchange, ignored -> { + routed.set(true); + return Mono.empty(); + })).verifyComplete(); + assertThat(routed).isTrue(); + } + verifyNoInteractions(client); + } + + private static ServerWebExchange exchange(String path, Map claims) { + Jwt.Builder jwt = Jwt.withTokenValue("already-verified-by-resource-server") + .header("alg", "RS256") + .subject("42") + .issuedAt(Instant.now().minusSeconds(5)) + .expiresAt(Instant.now().plusSeconds(300)); + claims.forEach(jwt::claim); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get(path).build()); + return exchange.mutate() + .principal(Mono.just(new JwtAuthenticationToken(jwt.build(), + List.of(new SimpleGrantedAuthority("ROLE_WAREHOUSE_PICKER"))))) + .build(); + } + + private static Map claims(int tokenVersion, Long warehouseId) { + var claims = new java.util.LinkedHashMap(); + claims.put("token_version", tokenVersion); + if (warehouseId != null) claims.put("overseas_warehouse_id", warehouseId); + return claims; + } + + private static String body(ServerWebExchange exchange) { + return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse()) + .getBodyAsString().block(); + } +} diff --git a/iam/AGENTS.md b/iam/AGENTS.md index 22d1fb7e3..be2a0b5e5 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -4,6 +4,7 @@ - 负责客户主档、成员、企业资料/审核、平台用户、角色权限、数据范围和授权快照。 - 账号凭据/Token 属于 `auth/`,文件内容属于 `file/`;资质登记只接收 File 的内部元数据调用。 +- 修改用户可见层级、菜单权限、列表/详情只读语义或权限资源前,先读 `../../docs/平台客户层级与权限契约.md`。 - 当前仅“平台—客户”两级:`iam_tenant.id=0` 为平台作用域,`id>0` 为客户主档;不得新增真实租户层。 ## 模块入口 @@ -11,10 +12,12 @@ | 目标 | 入口 | |---|---| | 客户资料/审核 | `controller/PlatformTenantController.java`、`service/TenantService.java` | +| 客户默认尺寸初始化、重试与补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults` | | 商户成员与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java` | -| 平台用户 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java` | +| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | +| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../lastleg-service/security/` | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | | File 资质元数据协作 | `controller/InternalTenantController.java`、`service/ProfileResourceService.java`、`client/AuthAccountClient.java` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | @@ -25,7 +28,10 @@ - 所有客户查询按 `tenant_id` 过滤;平台用户只能 `tenant_id=0`。必填资质审核通过才具备下单资格。 - 成员/账号跨 IAM/Auth 保持幂等补偿;资质跨 IAM/File 不保存文件内容。 +- 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 - 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 +- 只有平台 `WAREHOUSE_PICKER` 可保存 `overseasWarehouseId`,且必须为单个有效海外仓;非拣货员必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 +- `IamTenantStatusChanged` 是安全可用性快照,不暴露客户建档/审核流程状态:仅 IAM 业务状态 `ACTIVE` 发布 `ACTIVE`,`PROFILE_REQUIRED`、`PENDING`、`REJECTED`、`DISABLED`、`ARCHIVED` 及其他不可用状态统一发布 `DISABLED`。 ## 全量验收导航 diff --git a/iam/SECURITY-EVENT-AUDIT.md b/iam/SECURITY-EVENT-AUDIT.md new file mode 100644 index 000000000..856b61edf --- /dev/null +++ b/iam/SECURITY-EVENT-AUDIT.md @@ -0,0 +1,41 @@ +# IAM 跨区安全事件契约 + +## 边界 + +IAM 使用独立的 `iam_security_event_outbox`。业务状态、权限版本与 Outbox 在同一数据库事务内提交;尾程按 `eventId` 幂等消费,并按信封 `aggregateVersion` 拒绝旧事件。事件不包含密码、令牌、联系方式、角色全集或权限全集。 + +## V1 事件 + +三个事件均使用标准 `EventEnvelope`,`eventVersion=1`、`sourceRegion=CN`、`targetRegion=OVERSEAS`。 + +1. `IamUserStatusChanged` + - 聚合:`aggregateType=USER`、`aggregateId=authUserId` + - payload:`authUserId`、`tenantId`、`appCode`、`status`、`permissionVersion`、`changedAt` + - status:`ACTIVE`、`DISABLED` +2. `IamTenantStatusChanged` + - 聚合:`aggregateType=TENANT`、`aggregateId=tenantId` + - payload:`tenantId`、`status`、`changedAt` + - status:`ACTIVE`、`DISABLED` + - `aggregateVersion` 来自 `iam_security_tenant_version.status_version`,每次状态变化单调递增 +3. `IamPermissionVersionChanged` + - 聚合:`aggregateType=AUTHORIZATION`、`aggregateId=authUserId` + - payload:`authUserId`、`tenantId`、`appCode`、`permissionVersion`、`reason`、`changedAt` + - `permissionVersion` 来自 `iam_security_subject_version.permission_version`,按账号单调递增 + +## 当前接入 + +- 企业成员、平台内部用户和平台用户启停均写 `IamUserStatusChanged`。 +- 客户创建、审核结果及启停写 `IamTenantStatusChanged`。 +- 成员角色、平台内部用户角色及角色权限变更写 `IamPermissionVersionChanged`。 + +普通 Admin/Client 的即时失效只覆盖用户停用、客户停用和权限版本推进。`token_version` 不进入 V1 投影,也不传给 Lastleg 校验;其即时权威比对只用于海外 PDA 向 Auth 核验仓绑定与会话版本。 + +## 尾程权限 + +`lastleg-permissions.sql` 初始化以下平台权限,并授予 `PLATFORM_ADMIN`: + +- `platform:lastleg:order:list` +- `platform:lastleg:order:detail` +- `platform:lastleg:order:assign` +- `platform:lastleg:order:transfer` +- `platform:lastleg:order:merge-pallet` diff --git a/iam/pom.xml b/iam/pom.xml index 2c35a4bc9..7f583178c 100644 --- a/iam/pom.xml +++ b/iam/pom.xml @@ -5,6 +5,7 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml iam-service + com.baiyue.sharedcommon-event-contract${project.version} com.baiyue.sharedbaiyue-common-audit${project.version} org.apache.rocketmqrocketmq-spring-boot-starter com.baiyue.sharedbaiyue-common-openapi${project.version} diff --git a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java new file mode 100644 index 000000000..c68eac775 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java @@ -0,0 +1,18 @@ +package com.baiyue.iam.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 = "iamSecurityEventBridgeClient", + url = "${baiyue.security-event.event-bridge-url:}") +public interface SecurityEventBridgeClient { + @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/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index d51fe0878..9a5e0642c 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -17,6 +17,7 @@ import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse; import com.baiyue.iam.service.TenantGuard; import com.baiyue.iam.service.TenantService; import com.baiyue.iam.service.MemberService; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import com.baiyue.iam.dto.PlatformCustomerMemberDtos; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -77,6 +78,24 @@ public class PlatformTenantController { return ApiResponse.success(service.merchant(tenantId)); } + @PostMapping("/tenants/{tenantId:\\d+}/dimension-initialization/retry") + @PreAuthorize("hasAuthority('platform:customer:edit')") + @Operation(summary = "重试客户默认尺寸初始化") + public ApiResponse retryDimensionInitialization( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.retryDimensionInitialization(tenantId)); + } + + @GetMapping("/tenants/{tenantId:\\d+}/dimension-initialization") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询客户默认尺寸初始化诊断") + public ApiResponse dimensionInitializationStatus( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.dimensionInitializationStatus(tenantId)); + } + @GetMapping("/tenants/contact-phone-availability") @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") @Operation(summary = "校验客户联系电话唯一性") diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java index 1ea405654..acc77d13d 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java @@ -39,5 +39,14 @@ public final class AuthorizationDtos { private String clientType; private Set roles; private Set permissions; + private Long permissionVersion; + private Long overseasWarehouseId; + + public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, + Long permissionVersion) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, null); + } } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java index b00df77b8..266bd6c2d 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -20,7 +20,8 @@ public final class OnboardingDtos { String address ) { } - public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus) { } + public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus) { } public record QualificationSubmissionRequest( @NotNull Long tenantId, diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java index 7c28c5dd7..79dae468b 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java @@ -20,16 +20,28 @@ public final class PlatformUserManagementDtos { @Email @Size(max = 128) String email, @NotNull Long tenantId, @NotNull List roleIds, + Long overseasWarehouseId, @NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, - @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { + public CreateUserRequest(String username, String displayName, String phone, String email, + Long tenantId, List roleIds, String password, String status) { + this(username, displayName, phone, email, tenantId, roleIds, null, password, status); + } + } public record UpdateUserRequest( @NotBlank @Size(max = 64) String displayName, @NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone, @Email @Size(max = 128) String email, @NotNull Long tenantId, - @NotNull List roleIds) { } + @NotNull List roleIds, + Long overseasWarehouseId) { + public UpdateUserRequest(String displayName, String phone, String email, + Long tenantId, List roleIds) { + this(displayName, phone, email, tenantId, roleIds, null); + } + } public record ChangeStatusRequest(@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) @@ -40,7 +52,7 @@ public final class PlatformUserManagementDtos { String displayName, String phone, String email, Long tenantId, String tenantName, String memberType, String memberNo, String status, List roleIds, List roleNames, LocalDateTime lastLoginAt, - boolean protectedAccount) { } + boolean protectedAccount, Long overseasWarehouseId) { } public record TenantOption(Long id, String name, String status) { } public record RoleOption(Long id, Long tenantId, String name, String status) { } diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 40e3422e9..69a08ec1c 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -133,6 +133,7 @@ public final class TenantDtos { @Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要") public record TenantResponse(Long id, String tenantCode, String tenantName, String status, + String dimensionInitializationStatus, String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName, String contactPhone, String contactEmail, String contactWechat, String servicePackage, String salesOwnerName, Integer paymentTermDays, String enterpriseType, diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java b/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java index 1b045d0ac..dfdaa658a 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java @@ -9,6 +9,6 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus. @Getter @Setter @TableName("iam_membership") public class IamMembership extends BaseEntity { - @TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt; + @TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private Long overseasWarehouseId; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt; } diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java index 1761b7a81..bc3971d9b 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java @@ -22,6 +22,7 @@ public class IamTenant extends BaseEntity { private String tenantName; private String tenantType; private String status; + private String dimensionInitializationStatus; private String legalName; private String legalRepresentative; private String unifiedSocialCreditCode; diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index 1a65a7d07..6d57e9632 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -62,4 +62,11 @@ public interface AuthorizationQueryMapper { @Param("tenantId") long tenantId, @Param("membershipId") long membershipId, @Param("appCode") String appCode); + + @Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}") + long findPermissionVersion(@Param("userId") long userId); + + @Select("SELECT overseas_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0") + Long findOverseasWarehouseId(@Param("tenantId") long tenantId, + @Param("membershipId") long membershipId); } diff --git a/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java new file mode 100644 index 000000000..eaf32fa8b --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java @@ -0,0 +1,13 @@ +package com.baiyue.iam.model; + +import java.time.LocalDateTime; + +public record CustomerDimensionInitializationStatus( + long tenantId, + String status, + int attemptCount, + LocalDateTime nextRetryAt, + String lastErrorCode, + String lastErrorMessage, + LocalDateTime updatedAt +) { } diff --git a/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java new file mode 100644 index 000000000..51b3aea9a --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java @@ -0,0 +1,12 @@ +package com.baiyue.iam.model; + +public record CustomerDimensionInitializationTask( + long tenantId, + long operatorId, + String initializationKey, + String action, + String status, + int attemptCount, + int taskVersion) { +} + diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java new file mode 100644 index 000000000..cc9d35ba8 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java @@ -0,0 +1,123 @@ +package com.baiyue.iam.repository; + +import com.baiyue.iam.model.CustomerDimensionInitializationTask; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public class CustomerDimensionInitializationRepository { + @Resource private JdbcTemplate jdbc; + + public void enqueue(long tenantId, long operatorId, String initializationKey) { + jdbc.update("INSERT INTO iam_customer_dimension_initialization_task(tenant_id,operator_id," + + "initialization_key,action,status,attempt_count,next_retry_at,task_version) " + + "VALUES(?,?,?,'INITIALIZE','PENDING',0,NOW(3),0)", + tenantId, operatorId, initializationKey); + } + + public void requestCompensation(long tenantId, long operatorId, String initializationKey) { + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET operator_id=?," + + "initialization_key=?,action='COMPENSATE',status='PENDING',attempt_count=0," + + "next_retry_at=NOW(3),lease_owner=NULL,lease_until=NULL,last_error_code=NULL," + + "last_error_message=NULL,task_version=task_version+1,updated_at=NOW(3) WHERE tenant_id=?", + operatorId, initializationKey, tenantId); + if (updated == 0) { + jdbc.update("INSERT INTO iam_customer_dimension_initialization_task(tenant_id,operator_id," + + "initialization_key,action,status,attempt_count,next_retry_at,task_version) " + + "VALUES(?,?,?,'COMPENSATE','PENDING',0,NOW(3),0)", + tenantId, operatorId, initializationKey); + } + } + + public boolean manualRetry(long tenantId) { + return jdbc.update("UPDATE iam_customer_dimension_initialization_task SET action='INITIALIZE'," + + "status='PENDING',attempt_count=0,next_retry_at=NOW(3),lease_owner=NULL,lease_until=NULL," + + "last_error_code=NULL,last_error_message=NULL,task_version=task_version+1,updated_at=NOW(3) " + + "WHERE tenant_id=? AND status='FAILED'", tenantId) == 1; + } + + public Optional findStatus(long tenantId) { + List rows = jdbc.query( + "SELECT tenant_id,status,attempt_count,next_retry_at,last_error_code,last_error_message,updated_at " + + "FROM iam_customer_dimension_initialization_task WHERE tenant_id=?", + (rs, row) -> new CustomerDimensionInitializationStatus( + rs.getLong("tenant_id"), rs.getString("status"), rs.getInt("attempt_count"), + rs.getTimestamp("next_retry_at").toLocalDateTime(), rs.getString("last_error_code"), + rs.getString("last_error_message"), rs.getTimestamp("updated_at").toLocalDateTime()), tenantId); + return rows.stream().findFirst(); + } + + public void recoverExpiredLeases() { + jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='PENDING',lease_owner=NULL," + + "lease_until=NULL,next_retry_at=NOW(3),task_version=task_version+1,updated_at=NOW(3) " + + "WHERE status='SENDING' AND lease_until candidates(int limit) { + return jdbc.queryForList("SELECT tenant_id FROM iam_customer_dimension_initialization_task " + + "WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY next_retry_at,tenant_id LIMIT " + + Math.max(1, Math.min(limit, 200)), Long.class); + } + + @Transactional + public CustomerDimensionInitializationTask claim(long tenantId, String workerId, LocalDateTime now) { + int claimed = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='SENDING'," + + "lease_owner=?,lease_until=?,task_version=task_version+1,updated_at=? " + + "WHERE tenant_id=? AND status='PENDING' AND next_retry_at<=?", + workerId, now.plusMinutes(2), now, tenantId, now); + if (claimed != 1) return null; + return jdbc.queryForObject("SELECT tenant_id,operator_id,initialization_key,action,status," + + "attempt_count,task_version FROM iam_customer_dimension_initialization_task " + + "WHERE tenant_id=? AND status='SENDING' AND lease_owner=?", + (rs, row) -> new CustomerDimensionInitializationTask( + rs.getLong("tenant_id"), rs.getLong("operator_id"), rs.getString("initialization_key"), + rs.getString("action"), rs.getString("status"), rs.getInt("attempt_count"), + rs.getInt("task_version")), tenantId, workerId); + } + + @Transactional + public boolean markReady(CustomerDimensionInitializationTask task, String workerId, Long configId) { + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='READY'," + + "remote_config_id=?,lease_owner=NULL,lease_until=NULL,last_error_code=NULL," + + "last_error_message=NULL,completed_at=NOW(3),updated_at=NOW(3) " + + "WHERE tenant_id=? AND action='INITIALIZE' AND status='SENDING' AND lease_owner=? AND task_version=?", + configId, task.tenantId(), workerId, task.taskVersion()); + if (updated == 1) { + jdbc.update("UPDATE iam_tenant SET dimension_initialization_status='READY',updated_at=NOW(3) " + + "WHERE id=? AND deleted=0", task.tenantId()); + } + return updated == 1; + } + + public boolean markCancelled(CustomerDimensionInitializationTask task, String workerId) { + return jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='CANCELLED'," + + "lease_owner=NULL,lease_until=NULL,last_error_code=NULL,last_error_message=NULL," + + "completed_at=NOW(3),updated_at=NOW(3) WHERE tenant_id=? AND action='COMPENSATE' " + + "AND status='SENDING' AND lease_owner=? AND task_version=?", + task.tenantId(), workerId, task.taskVersion()) == 1; + } + + @Transactional + public boolean recordFailure(CustomerDimensionInitializationTask task, String workerId, int nextAttempt, + LocalDateTime nextRetryAt, String code, String message, boolean exhausted) { + String status = exhausted ? "FAILED" : "PENDING"; + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status=?,attempt_count=?," + + "next_retry_at=?,lease_owner=NULL,lease_until=NULL,last_error_code=?,last_error_message=?," + + "task_version=task_version+1,updated_at=NOW(3) WHERE tenant_id=? AND action=? " + + "AND status='SENDING' AND lease_owner=? AND task_version=?", + status, nextAttempt, nextRetryAt, code, message, task.tenantId(), task.action(), workerId, + task.taskVersion()); + if (updated == 1 && exhausted && "INITIALIZE".equals(task.action())) { + jdbc.update("UPDATE iam_tenant SET dimension_initialization_status='FAILED',updated_at=NOW(3) " + + "WHERE id=? AND deleted=0", task.tenantId()); + } + return updated == 1; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java deleted file mode 100644 index cf540d3c5..000000000 --- a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.baiyue.iam.securityevent; - -import jakarta.annotation.Resource; -import org.apache.rocketmq.spring.core.RocketMQTemplate; -import org.apache.rocketmq.spring.support.RocketMQHeaders; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.messaging.support.MessageBuilder; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import java.util.UUID; - -@Component -@ConditionalOnProperty(name="baiyue.security-event.publisher-enabled",havingValue="true") -public class SecurityEventOutboxRelay { - @Resource private SecurityEventRepository repository; - @Resource private RocketMQTemplate rocketMQ; - @Value("${baiyue.security-event.topic:baiyue-security-event-v1}") private String topic; - private final String owner=UUID.randomUUID().toString(); - @Scheduled(fixedDelayString="${baiyue.security-event.poll-interval-ms:5000}") - public void relay(){for(String id:repository.pendingIds(50)){if(!repository.claim(id,owner))continue;var row=repository.find(id);try{rocketMQ.syncSend(topic+":"+row.eventType(),MessageBuilder.withPayload(row.payload()).setHeader(RocketMQHeaders.KEYS,row.eventId()).setHeader("eventId",row.eventId()).build(),3000);repository.sent(id,owner);}catch(RuntimeException ex){repository.failed(id,owner,row.attempts(),ex.getMessage());}}} -} diff --git a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java b/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java deleted file mode 100644 index 930ae9f69..000000000 --- a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.baiyue.iam.securityevent; - -import jakarta.annotation.Resource; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Repository; -import java.util.List; - -@Repository -public class SecurityEventRepository { - @Resource private JdbcTemplate jdbc; - public long incrementPermissionVersion(long authUserId) { - jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId); - return jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=?", Long.class, authUserId); - } - public void append(String id,String type,String aggregateType,String aggregateId,String payload) { - jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json,status,attempt_count,next_retry_at) VALUES (?,?,?,?,CAST(? AS JSON),'PENDING',0,CURRENT_TIMESTAMP(3))", id,type,aggregateType,aggregateId,payload); - } - public List pendingIds(int limit) { return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE (status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)) OR (status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3)) ORDER BY created_at LIMIT ?", String.class,limit); } - public boolean claim(String id,String owner) { return jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?,lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND),attempt_count=attempt_count+1 WHERE event_id=? AND ((status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)) OR (status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3)))",owner,id)==1; } - public Row find(String id) { return jdbc.queryForObject("SELECT event_id,event_type,payload_json,attempt_count FROM iam_security_event_outbox WHERE event_id=?",(rs,n)->new Row(rs.getString(1),rs.getString(2),rs.getString(3),rs.getInt(4)),id); } - public void sent(String id,String owner) { jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL,last_error=NULL WHERE event_id=? AND lease_owner=?",id,owner); } - public void failed(String id,String owner,int attempts,String error) { jdbc.update("UPDATE iam_security_event_outbox SET status=IF(attempt_count>=8,'DEAD','PENDING'),next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),lease_owner=NULL,lease_until=NULL,last_error=? WHERE event_id=? AND lease_owner=?",Math.min(900,5*(1< fields){String id=UUID.randomUUID().toString();Map payload=new LinkedHashMap<>();payload.put("eventId",id);payload.put("eventType",type);payload.put("schemaVersion",1);payload.put("occurredAt",Instant.now().toString());payload.putAll(fields);try{repository.append(id,type,aggregateType,Long.toString(aggregateId),json.writeValueAsString(payload));}catch(JsonProcessingException ex){throw new IllegalStateException("安全事件序列化失败",ex);}} -} diff --git a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java index c90ffec80..df90f8b1e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -46,7 +46,9 @@ public class AuthorizationSnapshotService { Set permissions = normalizedSet(mapper.findPermissions( request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), - appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions)); + appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions), + mapper.findPermissionVersion(request.getUserId()), + mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId())); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java new file mode 100644 index 000000000..817d7eb81 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java @@ -0,0 +1,101 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.iam.client.CustomerDimensionRuleClient; +import com.baiyue.iam.model.CustomerDimensionInitializationTask; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Set; +import java.util.UUID; + +@Component +@Slf4j +public class CustomerDimensionInitializationRelay { + private static final Set PERMANENT_CODES = Set.of("A0400", "A0403", "A0404", "A0409", "A0422"); + @Resource private CustomerDimensionInitializationRepository repository; + @Resource private CustomerDimensionRuleClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.customer-dimension-initialization.max-attempts:12}") private int maxAttempts; + private final String workerId = UUID.randomUUID().toString(); + + @Scheduled(fixedDelayString = "${baiyue.customer-dimension-initialization.fixed-delay-ms:5000}") + public void relay() { + repository.recoverExpiredLeases(); + for (Long tenantId : repository.candidates(50)) deliver(tenantId); + } + + void deliver(long tenantId) { + CustomerDimensionInitializationTask task = repository.claim(tenantId, workerId, LocalDateTime.now()); + if (task == null) return; + try { + if ("COMPENSATE".equals(task.action())) { + ApiResponse response = client.compensate( + internalToken, task.tenantId(), task.initializationKey()); + requireSuccess(response, false); + repository.markCancelled(task, workerId); + return; + } + ApiResponse response = client.initialize( + internalToken, new CustomerDimensionRuleClient.InitializationRequest( + task.tenantId(), task.operatorId(), task.initializationKey())); + requireSuccess(response, true); + if (response.data() == null || response.data().configId() == null) { + throw new DeliveryFailure("S0503", "Firstleg 未返回默认尺寸配置ID", false); + } + repository.markReady(task, workerId, response.data().configId()); + } catch (Exception failure) { + int nextAttempt = task.attemptCount() + 1; + boolean permanent = failure instanceof DeliveryFailure delivery && delivery.permanent; + boolean exhausted = permanent || nextAttempt >= maxAttempts; + String code = failure instanceof DeliveryFailure delivery ? delivery.code : "S0503"; + String message = safeMessage(failure); + repository.recordFailure(task, workerId, nextAttempt, + LocalDateTime.now().plusSeconds(backoffSeconds(nextAttempt)), code, message, exhausted); + log.warn("客户默认尺寸后台初始化失败 tenantId={}, action={}, attempt={}, exhausted={}, code={}, reason={}", + tenantId, task.action(), nextAttempt, exhausted, code, message); + } + } + + private static void requireSuccess(ApiResponse response, boolean requireData) { + if (response != null && response.successful() && (!requireData || response.data() != null)) return; + String code = response == null || response.code() == null ? "S0503" : response.code(); + String message = response == null || response.message() == null || response.message().isBlank() + ? "Firstleg 服务未确认客户默认尺寸初始化" : response.message(); + throw new DeliveryFailure(code, message, PERMANENT_CODES.contains(code)); + } + + private static long backoffSeconds(int attempt) { + return switch (Math.min(Math.max(attempt, 1), 5)) { + case 1 -> 10; + case 2 -> 30; + case 3 -> 120; + case 4 -> 600; + default -> 1800; + }; + } + + private static String safeMessage(Exception failure) { + String value = failure.getMessage(); + if (value == null || value.isBlank()) value = failure.getClass().getSimpleName(); + value = value.replaceAll("[\\r\\n\\t]+", " "); + value = value.replaceAll("(?i)Bearer\\s+[^\\s,;]+", "Bearer ***") + .replaceAll("(?i)(token|password)=([^\\s&,;]+)", "$1=***"); + return value.length() > 1000 ? value.substring(0, 1000) : value; + } + + private static final class DeliveryFailure extends RuntimeException { + private final String code; + private final boolean permanent; + private DeliveryFailure(String code, String message, boolean permanent) { + super(message); + this.code = code; + this.permanent = permanent; + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java index 32bd7217c..c5aaca617 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java @@ -1,119 +1,50 @@ package com.baiyue.iam.service; -import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.iam.client.CustomerDimensionRuleClient; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import jakarta.annotation.Resource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; -/** Provisions four default box-size dimensions and compensates them when customer creation rolls back. */ +/** Owns IAM's durable customer-dimension initialization intent; remote delivery is always background. */ @Service public class CustomerDimensionRuleInitializationService { - private static final Logger log = LoggerFactory.getLogger(CustomerDimensionRuleInitializationService.class); - private static final int MAX_ATTEMPTS = 3; private static final String KEY_PREFIX = "iam-customer-create:"; - - @Resource - private CustomerDimensionRuleClient client; - @Value("${baiyue.security.internal-service-token}") - private String internalToken; + @Resource private CustomerDimensionInitializationRepository repository; public InitializationResult initialize(long tenantId, long operatorId) { String initializationKey = initializationKey(tenantId); - RuntimeException lastFailure = null; - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - ApiResponse response = client.initialize( - internalToken, - new CustomerDimensionRuleClient.InitializationRequest( - tenantId, operatorId, initializationKey)); - if (response != null && response.successful() && response.data() != null) { - InitializationResult result = new InitializationResult( - response.data().createdByThisInitialization(), initializationKey); - registerRollbackCompensation(tenantId, result); - return result; - } - lastFailure = unavailable(response); - log.warn("客户4条默认箱规尺寸初始化失败,tenantId={}, attempt={}, code={}, requestId={}, traceId={}", - tenantId, attempt, response == null ? null : response.code(), - response == null ? null : response.requestId(), - response == null ? null : response.traceId()); - } catch (RuntimeException failure) { - lastFailure = failure; - log.warn("客户4条默认箱规尺寸初始化调用异常,tenantId={}, attempt={}", tenantId, attempt, failure); - } - } - compensateAfterUncertainInitialization(tenantId, initializationKey); - throw unavailable(lastFailure); + repository.enqueue(tenantId, operatorId, initializationKey); + return new InitializationResult(false, initializationKey, "PENDING"); } /** Used when Auth compensates a committed client registration. */ public void compensateRegistration(long tenantId) { - compensateRequired(tenantId, initializationKey(tenantId)); + repository.requestCompensation(tenantId, 0L, initializationKey(tenantId)); } - private void registerRollbackCompensation(long tenantId, InitializationResult result) { - if (!result.created() || !TransactionSynchronizationManager.isSynchronizationActive()) return; - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCompletion(int status) { - if (status != STATUS_ROLLED_BACK) return; - try { - compensateRequired(tenantId, result.initializationKey()); - } catch (RuntimeException compensationFailure) { - log.error("客户创建事务回滚后4条默认箱规尺寸补偿失败,tenantId={}, initializationKey={}", - tenantId, result.initializationKey(), compensationFailure); - } - } - }); - } - - private void compensateAfterUncertainInitialization(long tenantId, String initializationKey) { - try { - compensateRequired(tenantId, initializationKey); - } catch (RuntimeException compensationFailure) { - log.error("客户4条默认箱规尺寸初始化结果不确定且补偿失败,tenantId={}, initializationKey={}", - tenantId, initializationKey, compensationFailure); + public void retry(long tenantId) { + if (!repository.manualRetry(tenantId)) { + throw new BizException(ResultCode.CONFLICT, "默认尺寸初始化当前不可人工重试"); } } - private void compensateRequired(long tenantId, String initializationKey) { - RuntimeException lastFailure = null; - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - ApiResponse response = client.compensate(internalToken, tenantId, initializationKey); - if (response != null && response.successful()) return; - lastFailure = unavailable(response); - } catch (RuntimeException failure) { - lastFailure = failure; - } + public CustomerDimensionInitializationStatus status(long tenantId) { + return repository.findStatus(tenantId).orElseThrow( + () -> new BizException(ResultCode.NOT_FOUND, "默认尺寸初始化任务不存在")); + } + + public void requireReady(String status) { + if (!"READY".equals(status)) { + throw new BizException(ResultCode.CONFLICT, + "客户默认尺寸尚未就绪,当前状态: " + (status == null ? "PENDING" : status)); } - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, - "客户4条默认箱规尺寸补偿失败,请根据客户ID修复数据一致性: " + tenantId); - } - - private static BizException unavailable(ApiResponse response) { - String detail = response == null || response.message() == null || response.message().isBlank() - ? "Firstleg 服务未返回有效响应" : response.message(); - return new BizException(ResultCode.SERVICE_UNAVAILABLE, "4条默认箱规尺寸初始化失败: " + detail); - } - - private static BizException unavailable(RuntimeException cause) { - String detail = cause == null || cause.getMessage() == null || cause.getMessage().isBlank() - ? "Firstleg 服务暂时不可用" : cause.getMessage(); - return new BizException(ResultCode.SERVICE_UNAVAILABLE, - "4条默认箱规尺寸初始化失败,已重试 3 次: " + detail); } private static String initializationKey(long tenantId) { return KEY_PREFIX + tenantId; } - public record InitializationResult(boolean created, String initializationKey) { } + public record InitializationResult(boolean created, String initializationKey, String status) { } } diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index c75554ea2..cc35971c5 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -40,6 +40,8 @@ public class MemberService { private IamRoleMapper roleMapper; @Resource private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -102,6 +104,8 @@ public class MemberService { membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo()); membership.setStatus("ACTIVE"); membership.setJoinedAt(LocalDateTime.now()); repository.insert(membership); repository.replaceRoles(tenantId, membershipId, request.roleIds()); + securityEvents.userStatus(authId, tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(authId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); return response(tenantId, membership, user); } catch (RuntimeException exception) { try { @@ -159,6 +163,7 @@ public class MemberService { membership.setStatus(request.status()); membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); + securityEvents.userStatus(user.getAuthUserId(), tenantId, "MERCHANT", request.status()); } catch (RuntimeException localFailure) { try { authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus)); diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index db8c43964..a393d5cac 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -42,6 +42,7 @@ public class MerchantOnboardingService { @Resource private IamTenantReviewMapper reviewMapper; @Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @Resource private SecurityEventOutboxService securityEvents; @Transactional public MerchantRegistrationResponse register(MerchantRegistrationRequest request) { @@ -69,6 +70,7 @@ public class MerchantOnboardingService { tenant.setTenantName(request.companyName()); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + tenant.setDimensionInitializationStatus("PENDING"); tenant.setLegalName(request.companyName()); tenant.setLegalRepresentative(request.legalPerson()); tenant.setContactName(request.displayName()); @@ -77,6 +79,7 @@ public class MerchantOnboardingService { tenant.setDeleted(false); tenant.setVersion(0); tenantMapper.insert(tenant); + securityEvents.tenantStatus(tenantId, tenant.getStatus()); IamUser user = new IamUser(); user.setId(userId); @@ -108,6 +111,8 @@ public class MerchantOnboardingService { membershipRole.setTenantId(tenantId); membershipRole.setCreatedAt(LocalDateTime.now()); membershipRoleMapper.insert(membershipRole); + securityEvents.userStatus(request.authUserId(), tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(request.authUserId(), tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); if (StringUtils.hasText(request.address())) { IamTenantAddress address = new IamTenantAddress(); @@ -122,7 +127,8 @@ public class MerchantOnboardingService { } dimensionRuleInitialization.initialize(tenantId, request.authUserId()); overseasStorageFeeInitialization.initialize(tenantId, request.authUserId(), tenant.getTenantName()); - return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus()); + return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus(), + tenant.getDimensionInitializationStatus()); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 64b8df116..baa3d4658 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -38,6 +38,8 @@ public class PlatformInternalUserService { private IamRoleMapper roleMapper; @Resource private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -75,6 +77,7 @@ public class PlatformInternalUserService { repository.insertUser(user); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + publishInitialSecurityProjection(user); return response(membership, user, request.username(), roleIds); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); @@ -138,6 +141,7 @@ public class PlatformInternalUserService { user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); + securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", request.status()); return response(membership, user, authAccount.username(), roleIds); } catch (RuntimeException localFailure) { compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure); @@ -167,6 +171,8 @@ public class PlatformInternalUserService { repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); if (!new java.util.HashSet<>(currentRoleIds).equals(new java.util.HashSet<>(roleIds))) { revokeSessions(user.getAuthUserId()); + securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", + "MEMBER_ROLES_CHANGED"); } AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); return response(membership, user, authAccount.username(), roleIds); @@ -191,9 +197,16 @@ public class PlatformInternalUserService { IamMembership membership = newMembership(membershipId, user.getId(), memberNo); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + publishInitialSecurityProjection(user); return response(membership, user, authAccount.username(), roleIds); } + private void publishInitialSecurityProjection(IamUser user) { + securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", user.getStatus()); + securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", + "INITIAL_AUTHORIZATION"); + } + private InternalUserResponse response(IamMembership membership) { IamUser user = requiredUser(membership.getUserId()); AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index d35d25a17..b7e719747 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -43,6 +43,8 @@ public class PlatformUserManagementService { private IamRoleMapper roleMapper; @Resource private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -73,6 +75,7 @@ public class PlatformUserManagementService { long tenantId = request.tenantId(); requirePlatformTenant(tenantId); List roleIds = validateRoles(tenantId, request.roleIds()); + Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); long authUserId = IdWorker.getId(); long userId = IdWorker.getId(); long membershipId = IdWorker.getId(); @@ -97,11 +100,14 @@ public class PlatformUserManagementService { membership.setUserId(userId); membership.setMemberType("EMPLOYEE"); membership.setMemberNo(memberNo(tenantId, membershipId)); + membership.setOverseasWarehouseId(overseasWarehouseId); membership.setStatus(request.status()); membership.setJoinedAt(LocalDateTime.now()); membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); members.insert(membership); members.replaceRoles(tenantId, membershipId, roleIds); + securityEvents.userStatus(authUserId, tenantId, appCode, request.status()); + securityEvents.permissionChanged(authUserId, tenantId, appCode, "INITIAL_AUTHORIZATION"); return response(membership); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); @@ -119,6 +125,7 @@ public class PlatformUserManagementService { long newTenantId = request.tenantId(); requirePlatformTenant(newTenantId); List roleIds = validateRoles(newTenantId, request.roleIds()); + Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); List oldRoleIds = members.findRoleIds(oldTenantId, membershipId); if (isProtectedPlatformAdmin(membership, oldRoleIds) && (newTenantId != PLATFORM_TENANT_ID || !containsPlatformAdminRole(roleIds))) { @@ -132,6 +139,7 @@ public class PlatformUserManagementService { boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim()); boolean scopeChanged = oldTenantId != newTenantId; boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds)); + boolean warehouseChanged = !Objects.equals(membership.getOverseasWarehouseId(), overseasWarehouseId); if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(), new AuthAccountClient.ProfileRequest(request.displayName().trim()))); @@ -143,6 +151,7 @@ public class PlatformUserManagementService { user.setEmail(trimToNull(request.email())); user.setUserType(userType(newTenantId)); membership.setTenantId(newTenantId); + membership.setOverseasWarehouseId(overseasWarehouseId); if (scopeChanged && members.existsMemberNo(newTenantId, membership.getMemberNo(), membershipId)) { membership.setMemberNo(memberNo(newTenantId, membershipId)); } @@ -150,7 +159,7 @@ public class PlatformUserManagementService { members.save(membership); if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of()); members.replaceRoles(newTenantId, membershipId, roleIds); - if (rolesChanged && !profileChanged && !scopeChanged) { + if ((rolesChanged || warehouseChanged) && !profileChanged && !scopeChanged) { call(() -> authClient.revokeSessions(internalToken, new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); } @@ -185,6 +194,8 @@ public class PlatformUserManagementService { user.setStatus(request.status()); members.saveUser(user); members.save(membership); + securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(), + appCode(membership.getTenantId()), request.status()); } catch (RuntimeException localFailure) { call(() -> authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldAccount.status()))); @@ -218,7 +229,8 @@ public class PlatformUserManagementService { return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(), user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName, membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds, - roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds)); + roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds), + membership.getOverseasWarehouseId()); } private List platformRoles() { @@ -241,6 +253,18 @@ public class PlatformUserManagementService { return roleIds; } + private Long validateWarehouseBinding(List roleIds, Long overseasWarehouseId) { + boolean picker = roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull) + .anyMatch(role -> "WAREHOUSE_PICKER".equals(role.getRoleCode())); + if (picker && (overseasWarehouseId == null || overseasWarehouseId <= 0)) { + throw new BizException(ResultCode.BAD_REQUEST, "海外仓拣货员必须绑定一个有效海外仓"); + } + if (!picker && overseasWarehouseId != null) { + throw new BizException(ResultCode.BAD_REQUEST, "仅海外仓拣货员可绑定海外仓"); + } + return overseasWarehouseId; + } + private boolean isProtectedPlatformAdmin(IamMembership membership, List roleIds) { return membership.getTenantId() == PLATFORM_TENANT_ID && containsPlatformAdminRole(roleIds); } diff --git a/iam/src/main/java/com/baiyue/iam/service/RbacService.java b/iam/src/main/java/com/baiyue/iam/service/RbacService.java index 274b2eb75..6f1669ee6 100644 --- a/iam/src/main/java/com/baiyue/iam/service/RbacService.java +++ b/iam/src/main/java/com/baiyue/iam/service/RbacService.java @@ -33,6 +33,7 @@ public class RbacService { @Resource private RbacRepository repository; @Resource private MemberRepository memberRepository; @Resource private AuthAccountClient authClient; + @Resource private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -239,6 +240,7 @@ public class RbacService { var user = memberRepository.findUser(membership.getUserId()); if (user == null || user.getAuthUserId() == null) throw sessionRevocationUnavailable(); revokeSessions(List.of(user.getAuthUserId())); + securityEvents.permissionChanged(user.getAuthUserId(), tenantId, appCode, "MEMBER_ROLES_CHANGED"); } } @@ -256,7 +258,10 @@ public class RbacService { } } repository.replaceRolePermissions(tenantId, request.roleId(), request.permissionIds()); - revokeAssignedSessions(tenantId, request.roleId()); + List affected = repository.findAssignedAuthUserIds(tenantId, request.roleId()); + revokeSessions(affected); + affected.forEach(userId -> securityEvents.permissionChanged(userId, tenantId, appCode, + "ROLE_PERMISSIONS_CHANGED")); } private void revokeAssignedSessions(long tenantId, Long roleId) { diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java new file mode 100644 index 000000000..de41c5d9d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java @@ -0,0 +1,91 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.common.event.RegionCode; +import com.baiyue.iam.client.SecurityEventBridgeClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** IAM 安全事件至少一次投递器,支持过期租约回收、退避重试和死信。 */ +@Component +@Slf4j +public class SecurityEventOutboxRelay { + @Resource private JdbcTemplate jdbc; + @Resource private ObjectMapper objectMapper; + @Resource private SecurityEventBridgeClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.security-event.max-attempts:12}") private int maxAttempts; + private final String workerId = UUID.randomUUID().toString(); + + @Scheduled(fixedDelayString = "${baiyue.security-event.fixed-delay-ms:5000}") + public void relay() { + recoverExpiredLeases(); + for (String eventId : candidates()) deliver(eventId); + } + + @Transactional + public void deliver(String eventId) { + LocalDateTime now = LocalDateTime.now(); + int claimed = jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?," + + "lease_until=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", + workerId, now.plusMinutes(2), now, eventId, now); + if (claimed != 1) return; + String json = jdbc.queryForObject("SELECT payload_json FROM iam_security_event_outbox " + + "WHERE event_id=? AND status='SENDING' AND lease_owner=?", String.class, eventId, workerId); + try { + EventEnvelope envelope = objectMapper.readValue(json, EventEnvelope.class); + EventEnvelopeValidator.requireValid(envelope); + if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { + throw new IllegalStateException("IAM 安全事件必须从 CN 投影到 OVERSEAS"); + } + var response = client.receive(internalToken, envelope); + if (response == null || !response.successful() || response.data() == null + || !eventId.equals(response.data().eventId())) { + throw new IllegalStateException("事件桥未确认 IAM 安全事件"); + } + jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=?,lease_owner=NULL," + + "lease_until=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND lease_owner=?", + LocalDateTime.now(), LocalDateTime.now(), eventId, workerId); + } catch (Exception exception) { + fail(eventId, exception.getMessage()); + log.warn("IAM 安全事件投递失败,eventId={}, reason={}", eventId, exception.getMessage()); + } + } + + private List candidates() { + return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE status='PENDING' " + + "AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class); + } + + private void recoverExpiredLeases() { + jdbc.update("UPDATE iam_security_event_outbox SET status='PENDING',lease_owner=NULL,lease_until=NULL," + + "next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND lease_until= maxAttempts ? "DEAD" : "PENDING"; + long delay = Math.min(1800, 5L << Math.min(next, 8)); + jdbc.update("UPDATE iam_security_event_outbox SET status=?,attempt_count=?,next_retry_at=?," + + "lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=? WHERE event_id=? AND lease_owner=?", + status, next, LocalDateTime.now().plusSeconds(delay), truncate(message), LocalDateTime.now(), eventId, workerId); + } + + private String truncate(String value) { + String text = value == null ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " "); + return text.length() > 1000 ? text.substring(0, 1000) : text; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java new file mode 100644 index 000000000..520689a86 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java @@ -0,0 +1,69 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.event.EventEnvelope; +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.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +/** 在 IAM 状态事务内递增安全版本并写入可靠事件发送箱。 */ +@Service +public class SecurityEventOutboxService { + @Resource private JdbcTemplate jdbc; + @Resource private ObjectMapper objectMapper; + + public void userStatus(long authUserId, long tenantId, String appCode, String status) { + long version = bump(authUserId); + write(EventTypes.IAM_USER_STATUS_CHANGED, "USER", String.valueOf(authUserId), tenantId, version, + new IamSecurityEventsV1.UserStatusChanged(authUserId, tenantId, appCode, status, version, Instant.now())); + } + + public void tenantStatus(long tenantId, String status) { + jdbc.update("INSERT INTO iam_security_tenant_version(tenant_id,status_version) VALUES (?,1) " + + "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId); + long version = jdbc.queryForObject( + "SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=?", Long.class, tenantId); + String securityStatus = "ACTIVE".equals(status) ? "ACTIVE" : "DISABLED"; + write(EventTypes.IAM_TENANT_STATUS_CHANGED, "TENANT", String.valueOf(tenantId), tenantId, version, + new IamSecurityEventsV1.TenantStatusChanged(tenantId, securityStatus, Instant.now())); + } + + public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) { + long version = bump(authUserId); + write(EventTypes.IAM_PERMISSION_VERSION_CHANGED, "AUTHORIZATION", String.valueOf(authUserId), tenantId, version, + new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason, Instant.now())); + } + + private long bump(long authUserId) { + jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) " + + "ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId); + return jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=?", + Long.class, authUserId); + } + + private void write(String type, String aggregateType, String aggregateId, long tenantId, + long aggregateVersion, Object value) { + JsonNode payload = objectMapper.valueToTree(value); + String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)); + String flowId = EventIdentifiers.newUuid(); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), type, IamSecurityEventsV1.VERSION, + Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(tenantId), aggregateType, + aggregateId, aggregateVersion, null, flowId, flowId, null, payload, hash).requireValid(); + try { + jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + + "VALUES (?,?,?,?,CAST(? AS JSON))", envelope.eventId(), type, aggregateType, aggregateId, + objectMapper.writeValueAsString(envelope)); + } catch (Exception exception) { + throw new IllegalStateException("IAM 安全事件写入失败", exception); + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java index 7eb7d502b..6e1a6adbc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -43,6 +43,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni private IamRoleMapper roles; @Resource private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; private SecureRandom random = new SecureRandom(); @@ -112,6 +114,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni membership.setUpdatedBy(operatorId); members.insert(membership); members.replaceRoles(tenant.getId(), membershipId, List.of(ownerRole.getId())); + publishInitialSecurityProjection(authUserId, tenant.getId()); return result; } catch (RuntimeException localFailure) { compensate(context, result); @@ -152,6 +155,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni String password = temporaryPassword(); String username = createAuthAccountWithUniqueUsername(user.getAuthUserId(), password, tenant, owner.getId()); + publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId()); return ProvisioningResult.created(user.getAuthUserId(), username, password); } if (!"ACTIVE".equals(account.status()) @@ -166,6 +170,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni List repairedRoleIds = new java.util.ArrayList<>(roleIds); repairedRoleIds.add(ownerRole.getId()); members.replaceRoles(tenant.getId(), owner.getId(), repairedRoleIds.stream().distinct().toList()); + securityEvents.permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "MEMBER_ROLES_CHANGED"); } return ProvisioningResult.existing(); } @@ -230,6 +236,11 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni } } + private void publishInitialSecurityProjection(long authUserId, long tenantId) { + securityEvents.userStatus(authUserId, tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(authUserId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); + } + private void compensate(long authUserId) { try { ApiResponse response = authClient.compensate(internalToken, authUserId); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 00291bbd9..f93c268cc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -16,6 +16,7 @@ import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest; import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; @@ -71,6 +72,8 @@ public class TenantService { private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @Resource private TenantAdministratorProvisioningService administratorProvisioning; + @Resource + private SecurityEventOutboxService securityEvents; @Transactional @BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT", @@ -167,6 +170,7 @@ public class TenantService { tenant.setTenantName(request.tenantName()); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + tenant.setDimensionInitializationStatus("PENDING"); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); tenant.setUnifiedSocialCreditCode(null); @@ -186,6 +190,7 @@ public class TenantService { tenant.setCreatedBy(operatorId); tenant.setUpdatedBy(operatorId); repository.insertTenant(tenant); + securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); dimensionRuleInitialization.initialize(tenant.getId(), operatorId); overseasStorageFeeInitialization.initialize(tenant.getId(), operatorId, tenant.getTenantName()); var administrator = administratorProvisioning.provisionPlatformCreatedTenant(tenant, operatorId); @@ -269,6 +274,9 @@ public class TenantService { if (tenant == null) { throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); } + if (approve) { + dimensionRuleInitialization.requireReady(tenant.getDimensionInitializationStatus()); + } var initializationContext = new TenantApprovalInitializer.ApprovalContext( tenant, reviewId, reviewerId, comment); @@ -290,6 +298,7 @@ public class TenantService { tenant.setApprovedAt(approve ? LocalDateTime.now() : null); tenant.setApprovedBy(approve ? reviewerId : null); repository.saveTenant(tenant); + securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); for (var qualification : profileRepository.findQualifications(tenant.getId())) { if ("PENDING".equals(qualification.getStatus())) { qualification.setStatus(approve ? "APPROVED" : "REJECTED"); @@ -319,6 +328,10 @@ public class TenantService { return new OrderEligibilityResponse(false, tenant.getStatus(), "Enterprise information must be approved before ordering"); } + if (!"READY".equals(tenant.getDimensionInitializationStatus())) { + return new OrderEligibilityResponse(false, tenant.getStatus(), + "Customer default dimensions are not ready"); + } if (!hasAllQualificationsWithStatus(profileRepository.findQualifications(tenantId), "APPROVED")) { return new OrderEligibilityResponse(false, tenant.getStatus(), "All required enterprise qualifications must be approved"); @@ -336,6 +349,22 @@ public class TenantService { return response(requireMerchant(tenantId)); } + @Transactional + @BusinessAudit(event = "PLATFORM_CUSTOMER_DIMENSION_INITIALIZATION_RETRY", resourceType = "TENANT", + resourceId = "#tenantId", description = "平台重试客户默认尺寸初始化", captureBefore = false) + public TenantResponse retryDimensionInitialization(long tenantId) { + IamTenant tenant = requireMerchantForUpdate(tenantId); + dimensionRuleInitialization.retry(tenantId); + tenant.setDimensionInitializationStatus("PENDING"); + repository.saveTenant(tenant); + return response(tenant); + } + + public CustomerDimensionInitializationStatus dimensionInitializationStatus(long tenantId) { + requireMerchant(tenantId); + return dimensionRuleInitialization.status(tenantId); + } + public boolean contactPhoneAvailable(String contactPhone, Long tenantId) { String normalized = normalizeContactPhone(contactPhone); return tenantId == null @@ -403,6 +432,7 @@ public class TenantService { } tenant.setStatus(request.status()); repository.saveTenant(tenant); + securityEvents.tenantStatus(tenantId, request.status()); return response(tenant); } @@ -502,6 +532,7 @@ public class TenantService { private TenantResponse response(IamTenant tenant) { return new TenantResponse( tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), + tenant.getDimensionInitializationStatus(), tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(), tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(), tenant.getServicePackage(), tenant.getSalesOwnerName(), tenant.getPaymentTermDays(), tenant.getEnterpriseType(), @@ -524,6 +555,7 @@ public class TenantService { private TenantResponse summaryResponse(IamTenant tenant) { TenantResponse response = response(tenant); return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), + response.dimensionInitializationStatus(), response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(), maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(), response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(), @@ -534,6 +566,7 @@ public class TenantService { private TenantResponse merchantVisibleResponse(IamTenant tenant) { TenantResponse response = response(tenant); return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), + response.dimensionInitializationStatus(), response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(), response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(), response.establishedDate(), diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index e724fd6a4..5faf2522f 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -23,6 +23,11 @@ spring: namespace: ${NACOS_NAMESPACE:public} encode: UTF-8 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} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql index 4508de070..097bed02a 100644 --- a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -29,7 +29,9 @@ VALUES (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0), (5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0), - (5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0), + (5305000,0,'MERCHANT','MENU',5300000,'merchant:finance:statement','财务账单','/finance/statements',NULL,40,'ACTIVE',1,0), + (5305010,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:list','查看账单',NULL,'GET',10,'ACTIVE',1,0), + (5305011,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:view','查看账单详情及收款',NULL,'GET',20,'ACTIVE',1,0), (5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), @@ -101,18 +103,12 @@ UPDATE iam_permission p SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; -INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) -SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) - FROM iam_role r - JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' - WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') - AND p.permission_code='merchant:finance:statement:confirm'; - INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT' WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 - AND p.permission_code='merchant:finance:statement:confirm' + AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list', + 'merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0; diff --git a/iam/src/main/resources/db/customer-dimension-initialization-task.sql b/iam/src/main/resources/db/customer-dimension-initialization-task.sql new file mode 100644 index 000000000..2f1794d8e --- /dev/null +++ b/iam/src/main/resources/db/customer-dimension-initialization-task.sql @@ -0,0 +1,33 @@ +SET NAMES utf8mb4; +USE baiyue_iam; + +SET @customer_dimension_status_ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='iam_tenant' AND column_name='dimension_initialization_status'), + 'SELECT 1', + 'ALTER TABLE iam_tenant ADD COLUMN dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT ''READY'' COMMENT ''默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败'' AFTER status')); +PREPARE customer_dimension_status_stmt FROM @customer_dimension_status_ddl; +EXECUTE customer_dimension_status_stmt; +DEALLOCATE PREPARE customer_dimension_status_stmt; + +CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( + tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', + operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', + initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', + action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待投递、SENDING投递中、READY已就绪、FAILED失败、CANCELLED已补偿', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间', + lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间', + last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码', + last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID', + task_version INT 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 '任务更新时间', + completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间', + PRIMARY KEY (tenant_id), + KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), + KEY idx_customer_dimension_task_diagnosis (status,updated_at) +) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 65b0d2654..bc250abe7 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档', + dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败', legal_name VARCHAR(128) NULL COMMENT '企业法定名称', legal_representative VARCHAR(64) NULL COMMENT '法定代表人', unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', @@ -42,6 +43,28 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( KEY idx_iam_tenant_status (status, created_at) ) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键'; +CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( + tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', + operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', + initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', + action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待投递、SENDING投递中、READY已就绪、FAILED失败、CANCELLED已补偿', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间', + lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间', + last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码', + last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID', + task_version INT 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 '任务更新时间', + completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间', + PRIMARY KEY (tenant_id), + KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), + KEY idx_customer_dimension_task_diagnosis (status,updated_at) +) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; + CREATE TABLE IF NOT EXISTS iam_tenant_review ( id BIGINT NOT NULL COMMENT '审核记录ID', tenant_id BIGINT NOT NULL COMMENT '被审核客户ID和数据隔离键', @@ -114,6 +137,7 @@ CREATE TABLE IF NOT EXISTS iam_membership ( user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', + overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', joined_at DATETIME(3) NULL COMMENT '加入客户企业时间', left_at DATETIME(3) NULL COMMENT '离开客户企业时间', diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql new file mode 100644 index 000000000..dc8e69d70 --- /dev/null +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -0,0 +1,214 @@ +USE baiyue_iam; + +-- 四期 Admin 海外权限只使用 platform:overseas-*;列表权限包含详情只读。 +DELETE rp FROM iam_role_permission rp +JOIN iam_permission p ON p.id=rp.permission_id +WHERE p.permission_code LIKE 'platform:lastleg:%'; +UPDATE iam_permission SET status='INACTIVE', deleted=1 +WHERE permission_code LIKE 'platform:lastleg:%'; + +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, deleted) +VALUES + (5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0), + (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0), + (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), + (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), + (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), + (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), + (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0), + (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0), + (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,0), + (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), + (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), + (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), + (5107030,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:pallet:list','海外托盘','/overseas-warehouse/pallets','GET',730,'ACTIVE',1,0), + (5107031,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:create','创建海外托盘',NULL,'POST',731,'ACTIVE',1,0), + (5107032,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:edit','编辑海外托盘',NULL,'PUT',732,'ACTIVE',1,0), + (5107033,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:delete','删除海外托盘',NULL,'DELETE',733,'ACTIVE',1,0), + (5107040,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inventory:list','海外库存','/overseas-warehouse/inventory','GET',740,'ACTIVE',1,0), + (5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,0), + (5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,0), + (5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,0), + (5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,0), + (5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,0), + (5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,0), + (5107053,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:status','变更海外仓状态',NULL,'PATCH',753,'ACTIVE',1,0), + (5107060,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:location:list','海外库位','/overseas-warehouse/locations','GET',760,'ACTIVE',1,0), + (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), + (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), + (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), + (5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0), + (5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0), + (5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0), + (5107110,0,'PLATFORM','MENU',0,'platform:overseas-business:truck:list','本土卡车','/overseas-business/trucks','GET',810,'ACTIVE',1,0), + (5107111,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:create','创建本土卡车单',NULL,'POST',811,'ACTIVE',1,0), + (5107112,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:edit','编辑本土卡车单',NULL,'PUT',812,'ACTIVE',1,0), + (5107113,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:status','变更本土卡车状态',NULL,'PATCH',813,'ACTIVE',1,0), + (5107120,0,'PLATFORM','MENU',0,'platform:overseas-business:looseboard:list','本土散板','/overseas-business/looseboards','GET',820,'ACTIVE',1,0), + (5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,0), + (5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,0), + (5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,0), + (5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,0), + (5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,0), + (5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,0), + (5107203,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:status','变更海外仓报价状态',NULL,'PATCH',903,'ACTIVE',1,0), + (5107204,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:export','导出海外仓报价',NULL,'GET',904,'ACTIVE',1,0), + (5107210,0,'PLATFORM','MENU',0,'platform:overseas-master-data:exchange-rate:list','月度汇率','/overseas-master-data/exchange-rates','GET',910,'ACTIVE',1,0), + (5107211,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:create','创建月度汇率',NULL,'POST',911,'ACTIVE',1,0), + (5107212,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:edit','编辑月度汇率',NULL,'PUT',912,'ACTIVE',1,0), + (5107213,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:status','变更月度汇率状态',NULL,'PATCH',913,'ACTIVE',1,0), + (5107214,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:export','导出月度汇率',NULL,'GET',914,'ACTIVE',1,0), + (5107220,0,'PLATFORM','MENU',0,'platform:overseas-master-data:vehicle:list','车辆主数据','/overseas-master-data/vehicles','GET',920,'ACTIVE',1,0), + (5107221,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:create','创建车辆主数据',NULL,'POST',921,'ACTIVE',1,0), + (5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0), + (5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0), + (5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), + http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.id BETWEEN 5107000 AND 5107299 AND permission.permission_code<>'lastleg:pda:operate' +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id +JOIN iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id +WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'); + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code='lastleg:pda:operate' +WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS assert_overseas_pda_picker_permission; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_pda_picker_permission() +BEGIN + IF (SELECT COUNT(*) FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权'; + END IF; + IF EXISTS (SELECT 1 FROM iam_role_permission grant_row + JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_pda_picker_permission(); +DROP PROCEDURE assert_overseas_pda_picker_permission; + +-- 四期仓库后台角色严格最小授权:list 同时覆盖列表与详情,只读角色不得获得任何写权限。 +DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission; +CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission ( + role_code VARCHAR(64) NOT NULL, + permission_code VARCHAR(160) NOT NULL, + PRIMARY KEY (role_code, permission_code) +); +INSERT INTO tmp_phase4_warehouse_role_permission (role_code, permission_code) VALUES + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:purchase:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:truck:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-business:looseboard:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:last-mile-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inbound-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:relabel-order:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:inventory:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:warehouse:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:location:list'), + ('WAREHOUSE_TALLY','platform:overseas-warehouse:pallet:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:purchase:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:truck:list'), + ('WAREHOUSE_TALLY','platform:overseas-business:looseboard:list'); + +DELETE grant_row FROM iam_role_permission grant_row +JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id +JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id +LEFT JOIN tmp_phase4_warehouse_role_permission expected + ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY') + AND permission.permission_code LIKE 'platform:overseas-%' + AND expected.permission_code IS NULL; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM tmp_phase4_warehouse_role_permission expected +JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code + AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS assert_phase4_warehouse_role_minimum; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_warehouse_role_minimum() +BEGIN + IF (SELECT COUNT(*) FROM tmp_phase4_warehouse_role_permission) <> 20 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限矩阵数量异常'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission + WHERE permission_code REGEXP ':(create|edit|status|export|adjust|delete|review)$') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库只读角色错误获得写权限'; + END IF; + IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission expected + LEFT JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0 + LEFT JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0 + LEFT JOIN iam_role_permission grant_row ON grant_row.tenant_id=0 + AND grant_row.role_id=role.id AND grant_row.permission_id=permission.id + WHERE grant_row.role_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_warehouse_role_minimum(); +DROP PROCEDURE assert_phase4_warehouse_role_minimum; +DROP TEMPORARY TABLE tmp_phase4_warehouse_role_permission; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:inbound-order:fba-appointment', + 'platform:overseas-warehouse:inbound-order:smart-dispatch' + ) +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +-- Tail-leg order actions are available to the current platform administrator role. +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:last-mile-order:edit', + 'platform:overseas-warehouse:inbound-order:create' + ) +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index fcc5d4bde..0f00deb81 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -26,10 +26,7 @@ INSERT INTO iam_tenant ( approved_at, approved_by, created_by, deleted, version ) VALUES (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', - NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), - (10001, 'IT_TEST', 'IT 测试客户', 'MERCHANT', 'ACTIVE', 'IT 测试客户', - '91310000ITTEST0001', 'IT 测试客户管理员', '13800000000', 'it-test@example.com', - NOW(3), 1000001, 1000001, 0, 0) + NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), @@ -38,28 +35,10 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; --- The built-in IT customer must be immediately usable for order acceptance tests. --- These approved metadata rows intentionally have no document URL: initialization must not --- point at qualification files that do not exist in File service storage. -INSERT INTO iam_tenant_qualification ( - id, tenant_id, qualification_type, certificate_no, document_url, - valid_from, valid_to, status, review_comment, deleted -) VALUES - (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), - (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) -ON DUPLICATE KEY UPDATE - tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), - certificate_no = VALUES(certificate_no), status = 'APPROVED', - review_comment = VALUES(review_comment), deleted = 0; - INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES - (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0), - (2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员', '13800000000', - 'it-test@example.com', 'ACTIVE', 0, 0) + (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), status = 'ACTIVE', deleted = 0; @@ -67,8 +46,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_membership ( id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version ) VALUES - (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), - (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0) ON DUPLICATE KEY UPDATE member_type = VALUES(member_type), member_no = VALUES(member_no), status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; @@ -78,9 +56,7 @@ INSERT INTO iam_role ( ) VALUES (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), - (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), - (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), - (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -167,40 +143,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE id IN (5102019, 5102024, 5102025) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- Merchant: level-one menus, level-two menus and buttons. -INSERT INTO iam_permission - (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) -VALUES - (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), - (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), - (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), - (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), - (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), - (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), - (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), - (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), - (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), - (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), - (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), - (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), - (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), - (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), - (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), - (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), - (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), - (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), - (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), - (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), - (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), - (5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES - (3000001, 4000001, 0), - (3000002, 4000101, 10001) + (3000001, 4000001, 0) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- Full administrator roles receive all phase-one permissions in their application scope. @@ -209,11 +153,6 @@ SELECT 4000001, id, 0 FROM iam_permission WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - -- Limited platform and merchant operational roles. INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000002, id, 0 FROM iam_permission @@ -229,15 +168,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000102, id, 10001 FROM iam_permission -WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES (6000001, 4000001, 0, 'TENANT', NULL), (6000002, 4000002, 0, 'TENANT', NULL), - (6000003, 4000003, 0, 'TENANT', NULL), - (6000101, 4000101, 10001, 'TENANT', NULL), - (6000102, 4000102, 10001, 'TENANT', NULL) + (6000003, 4000003, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; diff --git a/iam/src/main/resources/db/phase1-member-permissions.sql b/iam/src/main/resources/db/phase1-member-permissions.sql index 8a88a7896..bddb3b44d 100644 --- a/iam/src/main/resources/db/phase1-member-permissions.sql +++ b/iam/src/main/resources/db/phase1-member-permissions.sql @@ -17,20 +17,11 @@ DEALLOCATE PREPARE member_no_index_stmt; INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) VALUES - (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), - (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), - (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND permission_code IN - ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND permission_code IN diff --git a/iam/src/main/resources/db/phase1-onboarding.sql b/iam/src/main/resources/db/phase1-onboarding.sql index a31f31127..27a7da36e 100644 --- a/iam/src/main/resources/db/phase1-onboarding.sql +++ b/iam/src/main/resources/db/phase1-onboarding.sql @@ -41,17 +41,34 @@ INSERT INTO iam_role ( ) ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; --- Clone the一期 merchant menu/button template into the global scope. +-- Global merchant permission template used by every formally onboarded customer. INSERT INTO iam_permission ( id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted ) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') +VALUES + (5300000, 0, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5301000, 0, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5302000, 0, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5300010, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5300020, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5300030, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5301010, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5301020, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5302010, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5302020, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5300011, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5300012, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5300021, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5300022, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:grant-role', '分配成员角色', NULL, NULL, 20, 'ACTIVE', 0), + (5300031, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5300032, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5301011, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5301012, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5301021, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5301022, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5301023, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; diff --git a/iam/src/main/resources/db/phase3-warehouse-roles.sql b/iam/src/main/resources/db/phase3-warehouse-roles.sql index 75bf068e1..5f39d36cf 100644 --- a/iam/src/main/resources/db/phase3-warehouse-roles.sql +++ b/iam/src/main/resources/db/phase3-warehouse-roles.sql @@ -140,6 +140,7 @@ DELETE role_permission WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') + AND NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate') AND expected.permission_code IS NULL; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) diff --git a/iam/src/main/resources/db/rbac-role-management-v2.sql b/iam/src/main/resources/db/rbac-role-management-v2.sql index 5228b9b71..87f2ea5de 100644 --- a/iam/src/main/resources/db/rbac-role-management-v2.sql +++ b/iam/src/main/resources/db/rbac-role-management-v2.sql @@ -19,9 +19,6 @@ VALUES (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), - (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), - (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), - (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) @@ -40,12 +37,6 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'MERCHANT' diff --git a/iam/src/main/resources/db/security-event-outbox.sql b/iam/src/main/resources/db/security-event-outbox.sql index e9afe00e5..ec4693cdb 100644 --- a/iam/src/main/resources/db/security-event-outbox.sql +++ b/iam/src/main/resources/db/security-event-outbox.sql @@ -2,20 +2,34 @@ USE baiyue_iam; CREATE TABLE IF NOT EXISTS iam_security_subject_version ( auth_user_id BIGINT NOT NULL COMMENT '认证账号ID', - permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,角色或权限变化时单调递增', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (auth_user_id) ) ENGINE=InnoDB COMMENT='安全主体权限版本'; +CREATE TABLE IF NOT EXISTS iam_security_tenant_version ( + tenant_id BIGINT NOT NULL COMMENT '客户ID', + status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (tenant_id) +) ENGINE=InnoDB COMMENT='客户状态事件版本'; + CREATE TABLE IF NOT EXISTS iam_security_event_outbox ( - event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', event_type VARCHAR(64) NOT NULL, - aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、ACCOUNT、AUTHORIZATION', aggregate_id VARCHAR(64) NOT NULL, - 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, next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - lease_owner VARCHAR(128) NULL, lease_until DATETIME(3) NULL, last_error VARCHAR(1000) NULL, - sent_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - 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) + 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) NULL COMMENT '租约持有者', + lease_until DATETIME(3) NULL COMMENT '租约到期时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次错误', + sent_at DATETIME(3) 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 COMMENT='IAM可靠安全事件本地发送箱'; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 5f5b22b77..a3d146541 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -59,7 +59,7 @@ paths: post: tags: [Platform Tenant / 平台租户] summary: Register enterprise tenant / 创建企业客户草稿 - description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Saving the customer does not create a review task. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据,保存客户不会创建审核单。 + description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Customer creation succeeds after IAM commits; default dimensions initialize reliably in background and are exposed as `PENDING/READY/FAILED`. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据;IAM 提交即代表客户创建成功,默认尺寸在后台可靠初始化并显式返回 PENDING/READY/FAILED。 requestBody: required: true content: @@ -123,6 +123,38 @@ paths: $ref: '#/components/schemas/PlatformCustomerMembersApiResponse' '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/dimension-initialization: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询客户默认尺寸初始化诊断 + description: 返回任务状态、尝试次数、下次重试时间和最后失败原因;不会把远端失败转换为空数据或成功。 + responses: + '200': + description: 初始化诊断查询成功 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/CustomerDimensionInitializationStatus'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/dimension-initialization/retry: + parameters: + - {$ref: '#/components/parameters/TenantId'} + post: + tags: [Platform Tenant / 平台租户] + summary: 人工重试失败的客户默认尺寸初始化 + description: 只允许 FAILED 任务重新进入 PENDING;稳定 initializationKey 不变。 + responses: + '200': {$ref: '#/components/responses/TenantSuccess'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/platform/v1/iam/tenants/{tenantId}/submit-review: parameters: - {$ref: '#/components/parameters/TenantId'} @@ -886,7 +918,7 @@ components: TenantCreationSuccess: {description: Enterprise tenant and one-time owner credentials returned / 返回企业客户及一次性管理员凭据, content: {application/json: {schema: {$ref: '#/components/schemas/TenantCreationApiResponse'}}}} BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Unauthorized: {description: Authentication required / 需要登录, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} - Forbidden: {description: Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + Forbidden: {description: 'Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配', content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} ServiceUnavailable: {description: Authentication service unavailable / 认证服务不可用, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} @@ -1085,12 +1117,13 @@ components: pages: {type: integer, minimum: 0} TenantResponse: type: object - required: [id, tenantCode, tenantName, status, servicePackage] + required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage] properties: id: {type: string, pattern: '^\d+$', example: '190000000000000001'} tenantCode: {type: string} tenantName: {type: string} status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} + dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭} legalName: {type: string} legalRepresentative: {type: string, maxLength: 64} unifiedSocialCreditCode: {type: string} @@ -1109,6 +1142,17 @@ components: internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见} createdAt: {type: string, format: date-time, description: 入驻时间} approvedAt: {type: string, format: date-time, description: 审批通过时间} + CustomerDimensionInitializationStatus: + type: object + required: [tenantId, status, attemptCount, nextRetryAt, updatedAt] + properties: + tenantId: {type: string, pattern: '^\d+$'} + status: {type: string, enum: [PENDING, SENDING, READY, FAILED, CANCELLED]} + attemptCount: {type: integer, minimum: 0} + nextRetryAt: {type: string, format: date-time} + lastErrorCode: {type: string, nullable: true} + lastErrorMessage: {type: string, nullable: true, description: 已清洗且截断的最后失败诊断} + updatedAt: {type: string, format: date-time} FinanceCustomerSummary: type: object required: [id, tenantCode, tenantName, status] @@ -1279,6 +1323,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} status: {type: string, enum: [ACTIVE, DISABLED]} UpdatePlatformManagedUserRequest: @@ -1290,6 +1335,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} CreateInternalUserRequest: type: object required: [displayName] @@ -1362,6 +1408,7 @@ components: roleNames: {type: array, items: {type: string}} lastLoginAt: {type: string, format: date-time, nullable: true} protectedAccount: {type: boolean} + overseasWarehouseId: {type: string, pattern: '^\d+$', nullable: true, description: 海外仓PDA操作员绑定的唯一实际海外仓ID} CreateMemberRequest: type: object required: [username, password, displayName, memberType] diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index 99f5f992f..e184472d0 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -60,7 +60,7 @@ paths: - {$ref: '#/components/parameters/Size'} - {name: templateCode, in: query, schema: {type: string, maxLength: 64}} - {name: category, in: query, schema: {type: string, maxLength: 64}} - - {name: title, in: query, description: Fuzzy match template name or title template, schema: {type: string, maxLength: 128}} + - {name: title, in: query, description: 'Fuzzy match template name or title template', schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}} responses: {'200': {$ref: '#/components/responses/TemplatePage'}} post: @@ -92,7 +92,7 @@ paths: - {$ref: '#/components/parameters/Size'} - {name: taskNo, in: query, schema: {type: string, maxLength: 64}} - {name: category, in: query, schema: {type: string, maxLength: 64}} - - {name: title, in: query, description: Fuzzy match rendered title, schema: {type: string, maxLength: 128}} + - {name: title, in: query, description: 'Fuzzy match rendered title', schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}} responses: {'200': {$ref: '#/components/responses/TaskPage'}} /platform/v1/notification-tasks/{id}: @@ -145,17 +145,27 @@ components: tenantId: {type: integer, format: int64, minimum: 0} templateCode: {type: string, maxLength: 64} recipientType: {type: string, enum: [USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE]} - recipientIds: {type: array, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.} - roleCode: {type: string, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} + recipientIds: {type: array, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.} + roleCode: {type: string, minLength: 1, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} variables: {type: object, maxProperties: 32, additionalProperties: {type: string, maxLength: 2000}} scheduledAt: {type: string, format: date-time, nullable: true} idempotencyKey: {type: string, minLength: 8, maxLength: 128} sourceEventId: {type: string, maxLength: 128, nullable: true} - allOf: - - if: {properties: {recipientType: {enum: [USER, MEMBERSHIP]}}} - then: {required: [recipientIds]} - - if: {properties: {recipientType: {enum: [TENANT_ROLE, PLATFORM_ROLE]}}} - then: {required: [roleCode]} + oneOf: + - required: [recipientIds] + properties: + recipientType: {type: string, enum: [USER, MEMBERSHIP]} + not: {required: [roleCode]} + - required: [roleCode] + properties: + recipientType: {type: string, enum: [TENANT_ROLE, PLATFORM_ROLE]} + not: {required: [recipientIds]} + - properties: + recipientType: {type: string, enum: [TENANT_ALL]} + not: + anyOf: + - required: [recipientIds] + - required: [roleCode] CreateTemplateRequest: type: object required: [templateCode, templateName, category, titleTemplate, bodyTemplate, variableWhitelist] diff --git a/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java new file mode 100644 index 000000000..92692f92d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.iam.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SecurityEventBridgeClientContractTest { + private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); + + @Test + void feignUsesTheExplicitOverseasBridgePropertyInsteadOfSameNameDiscovery() { + FeignClient annotation = SecurityEventBridgeClient.class.getAnnotation(FeignClient.class); + + assertThat(annotation.name()).isEqualTo("event-bridge-service"); + assertThat(annotation.url()).isEqualTo("${baiyue.security-event.event-bridge-url:}"); + } + + @Test + void cnIamConfigurationTargetsTheOverseasBridgeNetworkAlias() throws Exception { + String application = Files.readString(REPOSITORY_ROOT.resolve("iam/src/main/resources/application.yml")); + String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/iam-service.yml")); + String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); + + assertThat(application).contains( + "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); + assertThat(nacos).contains( + "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); + assertThat(compose).contains( + "SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java index af6a9a172..d514112cd 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java @@ -69,6 +69,33 @@ class NotificationOpenApiContractTest { assertThat(map(map(taskDetail.get("properties")).get("attemptCount"))) .containsEntry("minimum", 0).containsEntry("maximum", 3); assertThat(document).contains("scheduler delivery starts after the retry delay"); + + Map createNotification = map(schemas.get("CreateNotificationRequest")); + Map selectorProperties = map(createNotification.get("properties")); + assertThat(map(selectorProperties.get("recipientIds"))).containsEntry("minItems", 1); + assertThat(map(selectorProperties.get("roleCode"))).containsEntry("minLength", 1); + List selectorBranches = list(createNotification.get("oneOf")); + assertThat(selectorBranches).hasSize(3); + + Map directRecipients = map(selectorBranches.get(0)); + assertThat(list(directRecipients.get("required"))).containsExactly("recipientIds"); + assertThat(list(map(directRecipients.get("not")).get("required"))).containsExactly("roleCode"); + assertThat(list(map(map(directRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("USER", "MEMBERSHIP"); + + Map roleRecipients = map(selectorBranches.get(1)); + assertThat(list(roleRecipients.get("required"))).containsExactly("roleCode"); + assertThat(list(map(roleRecipients.get("not")).get("required"))).containsExactly("recipientIds"); + assertThat(list(map(map(roleRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("TENANT_ROLE", "PLATFORM_ROLE"); + + Map allRecipients = map(selectorBranches.get(2)); + assertThat(list(map(map(allRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("TENANT_ALL"); + assertThat(list(map(allRecipients.get("not")).get("anyOf"))) + .extracting(value -> list(map(value).get("required"))) + .containsExactly(List.of("recipientIds"), List.of("roleCode")); + assertThat(document).doesNotContain("if:").doesNotContain("then:"); } private void assertRequestBody(Map paths, String path, String method) { diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java index 2190fbe70..f8cd21e76 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -31,6 +31,9 @@ class AdminPrototypeMenuPermissionSqlContractTest { assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); } assertThat(sql) + .contains("(5305000,0,'MERCHANT','MENU',5300000,'merchant:finance:statement'") + .contains("(5305010,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:list'") + .contains("(5305011,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:view'") .contains("(5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm'") .contains("r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER'") .contains("'platform:finance:fee-source:review' code, 5105041 parent_id") @@ -60,8 +63,8 @@ class AdminPrototypeMenuPermissionSqlContractTest { .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32") .contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33") .contains("Finance 导出和下载中心授权不完整") - .contains("merchant:finance:statement:confirm") - .contains("客户全局管理员缺少对账单确认权限") + .contains("客户全局 Finance 权限模板不完整") + .contains("客户全局管理员 Finance 模板授权不完整") .satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'")) .isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()"))); } diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java new file mode 100644 index 000000000..0c22f9ecf --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java @@ -0,0 +1,64 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerDimensionInitializationSqlContractTest { + @Test + void latestIamSqlContainsStatusAndDurableTask() throws Exception { + String sql = new ClassPathResource("db/iam.sql").getContentAsString(StandardCharsets.UTF_8); + assertContract(sql); + } + + @Test + void idempotentStandaloneAndUnifiedSqlContainTheSameTaskContract() throws Exception { + String standalone = new ClassPathResource("db/customer-dimension-initialization-task.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path unifiedPath = Files.exists(direct) ? direct + : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); + + assertContract(standalone); + assertContract(unified); + assertThat(standalone).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); + assertThat(unified).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); + } + + @Test + void schedulerRetryBudgetHasSafeOverridableDefaultsInAllDomesticProfiles() throws Exception { + String application = new ClassPathResource("application.yml").getContentAsString(StandardCharsets.UTF_8); + assertSchedulerConfig(application); + for (String profile : new String[] {"local", "cn"}) { + Path direct = Path.of("config", "nacos", profile, "iam-service.yml"); + Path config = Files.exists(direct) ? direct + : Path.of("..", "config", "nacos", profile, "iam-service.yml"); + assertSchedulerConfig(Files.readString(config, StandardCharsets.UTF_8)); + } + } + + private static void assertContract(String sql) { + assertThat(sql) + .contains("dimension_initialization_status") + .contains("iam_customer_dimension_initialization_task") + .contains("initialization_key") + .contains("attempt_count") + .contains("next_retry_at") + .contains("lease_until") + .contains("last_error_code") + .contains("task_version"); + } + + private static void assertSchedulerConfig(String yaml) { + assertThat(yaml) + .contains("customer-dimension-initialization:") + .contains("${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}") + .contains("${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java index 86f26ec04..64008a223 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java @@ -11,25 +11,42 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialIdentitySeedSqlContractTest { @Test - void initializesPlatformAndItTestCustomerIdentitiesWithStableIds() throws Exception { - assertIdentitySeed(new ClassPathResource("db/phase1-admin-rbac.sql") - .getContentAsString(StandardCharsets.UTF_8)); - assertIdentitySeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); + void initializesOnlyPlatformIdentityAndGlobalMerchantTemplates() throws Exception { + String identitySeed = new ClassPathResource("db/phase1-admin-rbac.sql") + .getContentAsString(StandardCharsets.UTF_8); + String templateSeed = new ClassPathResource("db/phase1-onboarding.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(unifiedSql(), StandardCharsets.UTF_8); + assertPlatformIdentitySeed(identitySeed); + assertPlatformIdentitySeed(unified); + assertGlobalMerchantTemplate(templateSeed); + assertGlobalMerchantTemplate(unified); } - private static void assertIdentitySeed(String sql) { + private static void assertPlatformIdentitySeed(String sql) { assertThat(sql) .contains("(0, 'PLATFORM', '虫虫出海平台'") - .contains("(10001, 'IT_TEST', 'IT 测试客户'") .contains("(2000001, 1000001, 'INTERNAL', '平台系统管理员'") - .contains("(2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员'") .contains("(3000001, 0, 2000001, 'EMPLOYEE'") - .contains("(3000002, 10001, 2000002, 'OWNER'") .contains("tenant_code = VALUES(tenant_code)") + .doesNotContain("merchant_admin") + .doesNotContain("IT_TEST") + .doesNotContain("(10001,") + .doesNotContain("(2000002,") + .doesNotContain("(3000002,") + .doesNotContain("4000101, 10001") .doesNotContain("PHASE1_DEMO") .doesNotContain("一期演示企业"); } + private static void assertGlobalMerchantTemplate(String sql) { + assertThat(sql) + .contains("4000100, 0, 'MERCHANT', 'MERCHANT_OWNER'") + .contains("5300000, 0, 'MERCHANT', 'MENU'") + .contains("5301024, 0, 'MERCHANT', 'BUTTON'") + .doesNotContain("WHERE tenant_id = 10001 AND app_code = 'MERCHANT'"); + } + private static Path unifiedSql() { Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java new file mode 100644 index 000000000..2182c43f1 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -0,0 +1,154 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasPhase4PermissionSqlContractTest { + private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+"); + private static final Set CUSTOM_ADMIN_PERMISSIONS = Set.of( + "platform:overseas-warehouse:inbound-order:fba-appointment", + "platform:overseas-warehouse:inbound-order:smart-dispatch"); + private static final List REQUIRED = List.of( + "platform:overseas-warehouse:last-mile-order:list", + "platform:overseas-warehouse:inbound-order:list", + "platform:overseas-warehouse:inbound-order:fba-appointment", + "platform:overseas-warehouse:inbound-order:smart-dispatch", + "platform:overseas-warehouse:relabel-order:status", + "platform:overseas-warehouse:pallet:list", + "platform:overseas-warehouse:inventory:adjust", + "platform:overseas-business:purchase:review", + "platform:overseas-business:truck:create", + "platform:overseas-business:looseboard:list", + "platform:overseas-master-data:quote:list", + "platform:overseas-master-data:exchange-rate:list", + "platform:overseas-master-data:vehicle:list"); + + @Test + void canonicalCatalogDisablesLegacyCodesAndExcludesFrozenDomains() throws Exception { + String sql = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + Set catalog = codes(sql); + assertThat(sql).contains(REQUIRED.toArray(String[]::new)); + assertThat(sql).contains("'lastleg:pda:operate'"); + assertPdaPickerGrant(sql); + assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); + assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); + assertCustomAdminGrant(sql); + } + + @Test + void unifiedSqlContainsTheSameCanonicalPermissionCatalog() throws Exception { + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); + String sql = Files.readString(path, StandardCharsets.UTF_8); + Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8)); + assertThat(codes(sql)).containsAll(moduleCatalog); + assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'"); + assertPdaPickerGrant(sql); + assertCustomAdminGrant(sql); + } + + @Test + void warehouseRolesReceiveOnlyTheTenPhase4ListPermissionsAndPickerRemainsPdaOnly() throws Exception { + String moduleSql = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); + String unifiedSql = Files.readString(path, StandardCharsets.UTF_8); + + for (String sql : List.of(moduleSql, unifiedSql)) { + String matrix = matrixBlock(sql); + assertThat(matrix).contains("WAREHOUSE_OPERATOR", "WAREHOUSE_TALLY"); + assertThat(codes(matrix)).containsExactlyInAnyOrder( + "platform:overseas-warehouse:last-mile-order:list", + "platform:overseas-warehouse:inbound-order:list", + "platform:overseas-warehouse:relabel-order:list", + "platform:overseas-warehouse:inventory:list", + "platform:overseas-warehouse:warehouse:list", + "platform:overseas-warehouse:location:list", + "platform:overseas-warehouse:pallet:list", + "platform:overseas-business:purchase:list", + "platform:overseas-business:truck:list", + "platform:overseas-business:looseboard:list"); + assertThat(matrix).doesNotContain("WAREHOUSE_PICKER"); + assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'"); + assertThat(sql).contains("assert_phase4_warehouse_role_minimum", "<> 20", + "role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY')"); + } + } + + @Test + void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception { + Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8)); + Set serviceCatalog = new LinkedHashSet<>(); + serviceCatalog.addAll(codes(repositoryFile( + "lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java"))); + serviceCatalog.addAll(codes(repositoryFile( + "warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java"))); + + assertThat(serviceCatalog).containsExactlyInAnyOrderElementsOf(moduleCatalog); + } + + private static Set codes(String source) { + Set values = new LinkedHashSet<>(); + CANONICAL.matcher(source).results().map(result -> result.group()).forEach(values::add); + return values; + } + + private static void assertCustomAdminGrant(String sql) { + String roleMatch = "UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'"; + int roleIndex = sql.indexOf(roleMatch); + assertThat(roleIndex).isGreaterThanOrEqualTo(0); + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", roleIndex); + int grantEnd = sql.indexOf(';', roleIndex); + assertThat(grantStart).isGreaterThanOrEqualTo(0); + assertThat(grantEnd).isGreaterThan(roleIndex); + assertThat(codes(sql.substring(grantStart, grantEnd))) + .containsExactlyInAnyOrderElementsOf(CUSTOM_ADMIN_PERMISSIONS); + } + + private static void assertPdaPickerGrant(String sql) { + assertThat(sql).contains( + "permission.permission_code<>'lastleg:pda:operate'", + "role.id=4000005", + "assert_overseas_pda_picker_permission", + "存在非仓库拣货员授权", + "role.role_code='WAREHOUSE_PICKER'", + "permission.permission_code='lastleg:pda:operate'", + "role.role_code='PLATFORM_ADMIN'", + "DELETE role_permission FROM"); + int picker = sql.indexOf("role.role_code='WAREHOUSE_PICKER'"); + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", picker); + int grantEnd = sql.indexOf(';', picker); + assertThat(grantStart).isGreaterThanOrEqualTo(0); + assertThat(grantEnd).isGreaterThan(picker); + assertThat(sql.substring(grantStart, grantEnd)).contains("lastleg:pda:operate"); + } + + private static String matrixBlock(String sql) { + int start = sql.indexOf("INSERT INTO tmp_phase4_warehouse_role_permission"); + int end = sql.indexOf("DELETE grant_row FROM", start); + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end); + } + + private static String repositoryFile(String relativePath) throws Exception { + Path path = Path.of(relativePath); + if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java new file mode 100644 index 000000000..55171dea6 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java @@ -0,0 +1,43 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SecurityEventSqlContractTest { + @Test + void securityOutboxHasVersionsAndReliableDeliveryColumns() throws Exception { + String sql = new ClassPathResource("db/security-event-outbox.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql) + .contains("iam_security_subject_version") + .contains("permission_version BIGINT NOT NULL DEFAULT 0") + .contains("iam_security_tenant_version") + .contains("status_version BIGINT NOT NULL DEFAULT 0") + .contains("iam_security_event_outbox") + .contains("payload_json JSON NOT NULL") + .contains("idx_security_outbox_publish") + .contains("PENDING", "SENDING", "SENT", "DEAD"); + } + + @Test + void unifiedMigrationIncludesSecurityEventSchemaAndAssertion() throws Exception { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path migration = Files.exists(direct) + ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql").normalize(); + String sql = Files.readString(migration, StandardCharsets.UTF_8); + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS iam_security_subject_version") + .contains("CREATE TABLE IF NOT EXISTS iam_security_tenant_version") + .contains("CREATE TABLE IF NOT EXISTS iam_security_event_outbox") + .contains("idx_security_outbox_publish", "idx_security_outbox_aggregate") + .contains("CALL assert_iam_security_event_schema();"); + assertThat(sql.indexOf("CALL assert_iam_security_event_schema();")) + .isLessThan(sql.lastIndexOf("SELECT 'PASS' AS migration_status")); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java index 9cb8406a9..36786af3e 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java @@ -45,6 +45,7 @@ class WarehouseRoleSqlContractTest { .contains("依赖的三期平台权限尚未完整部署") .contains("DELETE role_permission") .contains("expected.permission_code IS NULL") + .contains("role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate'") .contains("最小权限授权未完整写入") .contains("grant_row.tenant_id = 0) <> 19") .contains("(6000004, 4000004, 0, 'TENANT', NULL)") diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index 550066f8d..eeab42b78 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -29,6 +29,7 @@ class AuthorizationSnapshotServiceTest { .thenReturn(List.of("ADMIN", "OPERATOR")); when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) .thenReturn(List.of("order:read", "order:create")); + when(mapper.findOverseasWarehouseId(7L, 9L)).thenReturn(900L); var result = service .load(new SnapshotRequest(42L, 7L, 9L, "merchant")); @@ -37,6 +38,7 @@ class AuthorizationSnapshotServiceTest { assertThat(result.getClientType()).isEqualTo("MERCHANT"); assertThat(result.getRoles()).containsExactlyInAnyOrder("ADMIN", "OPERATOR"); assertThat(result.getPermissions()).containsExactlyInAnyOrder("order:read", "order:create"); + assertThat(result.getOverseasWarehouseId()).isEqualTo(900L); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java new file mode 100644 index 000000000..bdd692767 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java @@ -0,0 +1,105 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.iam.client.CustomerDimensionRuleClient; +import com.baiyue.iam.model.CustomerDimensionInitializationTask; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomerDimensionInitializationRelayTest { + @Mock private CustomerDimensionInitializationRepository repository; + @Mock private CustomerDimensionRuleClient client; + @InjectMocks private CustomerDimensionInitializationRelay relay; + + @BeforeEach + void configure() { + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 12); + } + + @Test + void successfulBackgroundInitializationMarksTenantReady() { + CustomerDimensionInitializationTask task = task("INITIALIZE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success( + new CustomerDimensionRuleClient.InitializationResponse(91L, true))); + + relay.deliver(10001L); + + verify(repository).markReady(task, workerId(), 91L); + verify(repository, never()).recordFailure(any(), anyString(), anyInt(), any(), anyString(), + anyString(), any(Boolean.class)); + } + + @Test + void transientFailureStaysPendingWithDiagnosticAndBackoff() { + CustomerDimensionInitializationTask task = task("INITIALIZE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("read timeout")); + + relay.deliver(10001L); + + ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); + verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(), + anyString(), exhausted.capture()); + assertFalse(exhausted.getValue()); + } + + @Test + void permanentRemoteRejectionBecomesFailedWithoutDefaultSuccess() { + CustomerDimensionInitializationTask task = task("INITIALIZE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenReturn(ApiResponse.failure( + ResultCode.CONFLICT, "invalid customer", "request-1", "trace-1")); + + relay.deliver(10001L); + + ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); + verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(), + anyString(), exhausted.capture()); + assertTrue(exhausted.getValue()); + verify(repository, never()).markReady(any(), anyString(), any()); + } + + @Test + void compensationIsAlsoDurableAndUsesTheStableKey() { + CustomerDimensionInitializationTask task = task("COMPENSATE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.compensate("internal-token", 10001L, "iam-customer-create:10001")) + .thenReturn(ApiResponse.success(null)); + + relay.deliver(10001L); + + verify(repository).markCancelled(task, workerId()); + } + + private CustomerDimensionInitializationTask task(String action, int attempts) { + return new CustomerDimensionInitializationTask(10001L, 9001L, "iam-customer-create:10001", + action, "SENDING", attempts, 3); + } + + private String workerId() { + return (String) ReflectionTestUtils.getField(relay, "workerId"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java index 459a72fab..b56e9c63d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java @@ -1,138 +1,61 @@ package com.baiyue.iam.service; -import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.iam.client.CustomerDimensionRuleClient; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.AfterEach; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; - -import java.util.List; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig(CustomerDimensionRuleInitializationService.class) -@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +@ExtendWith(MockitoExtension.class) class CustomerDimensionRuleInitializationServiceTest { - @MockBean private CustomerDimensionRuleClient client; - @Resource private CustomerDimensionRuleInitializationService service; - - @AfterEach - void clearSynchronization() { - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.clearSynchronization(); - } - } + @Mock private CustomerDimensionInitializationRepository repository; + @InjectMocks private CustomerDimensionRuleInitializationService service; @Test - void retriesOneAtomicFourBoxDefaultsCallWithAStableKey() { - when(client.initialize(anyString(), any())) - .thenThrow(new RuntimeException("timeout")) - .thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE, - "temporarily unavailable", "request-1", "trace-1")) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false))); - + void customerCreationOnlyPersistsBackgroundIntentWithStableKey() { var result = service.initialize(10001L, 9001L); assertFalse(result.created()); - ArgumentCaptor requests = - ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class); - verify(client, times(3)).initialize(anyString(), requests.capture()); - assertEquals(List.of("iam-customer-create:10001", "iam-customer-create:10001", - "iam-customer-create:10001"), - requests.getAllValues().stream().map( - CustomerDimensionRuleClient.InitializationRequest::initializationKey).toList()); - assertEquals(List.of(9001L, 9001L, 9001L), requests.getAllValues().stream() - .map(CustomerDimensionRuleClient.InitializationRequest::operatorId).toList()); - assertEquals(List.of(10001L, 10001L, 10001L), requests.getAllValues().stream() - .map(CustomerDimensionRuleClient.InitializationRequest::tenantId).toList()); + assertEquals("PENDING", result.status()); + assertEquals("iam-customer-create:10001", result.initializationKey()); + verify(repository).enqueue(10001L, 9001L, "iam-customer-create:10001"); } @Test - void reportsServiceUnavailableWhenFourBoxDefaultsFailThreeTimesAndTriesCompensation() { - when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("timeout")); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - - BizException failure = assertThrows(BizException.class, () -> service.initialize(10001L, 9001L)); - - assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); - verify(client, times(3)).initialize(anyString(), any()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void localRollbackCompensatesFourBoxDefaultsCreatedByThisRegistration() { - when(client.initialize(anyString(), any())) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true))); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - TransactionSynchronizationManager.initSynchronization(); - - var result = service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - assertTrue(result.created()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void localRollbackKeepsFourBoxDefaultsThatAlreadyExisted() { - when(client.initialize(anyString(), any())) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false))); - TransactionSynchronizationManager.initSynchronization(); - - service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - verify(client, never()).compensate(anyString(), anyLong(), anyString()); - } - - @Test - void responseTimeoutThenIdempotentFourBoxSuccessStillCompensatesOnLocalRollback() { - when(client.initialize(anyString(), any())) - .thenThrow(new RuntimeException("response timeout")) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true))); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - TransactionSynchronizationManager.initSynchronization(); - - service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - verify(client, times(2)).initialize(anyString(), any()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void committedClientRegistrationCanBeCompensatedWithItsDeterministicKey() { - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - + void committedRegistrationCompensationUsesTheSameStableKey() { service.compensateRegistration(10001L); + verify(repository).requestCompensation(10001L, 0L, "iam-customer-create:10001"); + } - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); + @Test + void failedTaskCanBeManuallyRequeued() { + when(repository.manualRetry(10001L)).thenReturn(true); + service.retry(10001L); + verify(repository).manualRetry(10001L); + } + + @Test + void nonFailedTaskCannotBeSilentlyRequeued() { + when(repository.manualRetry(10001L)).thenReturn(false); + BizException failure = assertThrows(BizException.class, () -> service.retry(10001L)); + assertEquals(ResultCode.CONFLICT, failure.getResultCode()); + } + + @Test + void dimensionsDependentFlowFailsClosedUntilReady() { + BizException pending = assertThrows(BizException.class, () -> service.requireReady("PENDING")); + BizException failed = assertThrows(BizException.class, () -> service.requireReady("FAILED")); + assertEquals(ResultCode.CONFLICT, pending.getResultCode()); + assertEquals(ResultCode.CONFLICT, failed.getResultCode()); + service.requireReady("READY"); } } diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index 4d1e69843..b69d85274 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -14,6 +14,7 @@ import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -34,6 +35,7 @@ class MemberServiceTest { @MockBean private TenantRepository tenantRepository; @MockBean private IamRoleMapper roleMapper; @MockBean private AuthAccountClient auth; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private MemberService service; private IamMembership member; private IamUser user; @@ -134,6 +136,27 @@ class MemberServiceTest { verify(repository, never()).insert(any()); } + @Test void createPublishesActiveUserAndInitialAuthorizationAfterRoleAssignment() { + IamRole role = activeMerchantRole(50L, 100L); + when(roleMapper.selectById(50L)).thenReturn(role); + when(auth.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary(eq("internal-token"), anyLong())).thenAnswer(invocation -> ApiResponse.success( + new AuthAccountClient.AccountSummary(invocation.getArgument(1), "member02", "成员", "ACTIVE"))); + when(repository.findRoleIds(eq(100L), anyLong())).thenReturn(List.of(50L)); + CreateMemberRequest request = new CreateMemberRequest("member02", "Password1", "成员", + null, null, "EMPLOYEE", "M-002", List.of(50L)); + + service.create(100L, request, Set.of("merchant:member:create", "merchant:member:grant-role")); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + long authUserId = account.getValue().accountId(); + verify(repository).replaceRoles(eq(100L), anyLong(), eq(List.of(50L))); + verify(securityEvents).userStatus(authUserId, 100L, "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(authUserId, 100L, "MERCHANT", "INITIAL_AUTHORIZATION"); + } + @Test void shouldHandleCase08() { IamRole role = activeMerchantRole(50L, 100L); when(roleMapper.selectById(50L)).thenReturn(role); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index ade730ea0..2b48686ef 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -41,6 +41,7 @@ class MerchantOnboardingServiceTest { @MockBean private IamTenantReviewMapper reviews; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private MerchantOnboardingService service; @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -67,8 +68,14 @@ class MerchantOnboardingServiceTest { verify(membershipRoles).insert(roleGrant.capture()); verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业"); + verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); + verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); + assertEquals("PENDING", result.dimensionInitializationStatus()); + assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); assertEquals("测试企业", tenant.getValue().getLegalName()); assertEquals("张三", tenant.getValue().getLegalRepresentative()); @@ -117,7 +124,7 @@ class MerchantOnboardingServiceTest { } @Test - void clientRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + void clientRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { IamRole ownerRole = new IamRole(); ownerRole.setId(4000100L); ownerRole.setTenantId(0L); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index bb1ed8d8f..6e8da47c5 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -30,6 +30,7 @@ class PlatformInternalUserServiceTest { @MockBean private MemberRepository repository; @MockBean private IamRoleMapper roleMapper; @MockBean private AuthAccountClient auth; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private PlatformInternalUserService service; private IamMembership membership; private IamUser user; @@ -84,6 +85,39 @@ class PlatformInternalUserServiceTest { verify(repository).insertUser(any(IamUser.class)); verify(repository).insert(any(IamMembership.class)); verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); + long authUserId = captor.getValue().accountId(); + verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + } + + @Test + void linkingExistingAccountPublishesInitialProjectionOnlyForNewMembership() { + IamRole role = new IamRole(); + role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); + when(roleMapper.selectById(40L)).thenReturn(role); + when(repository.findUserByAuthId(10L)).thenReturn(user); + when(repository.findByTenantAndAuthUser(0L, 10L)).thenReturn(null); + CreateInternalUserRequest request = new CreateInternalUserRequest(10L, null, "平台运营", + "13800138000", null, null, null, "PLATFORM-LINK", List.of(40L)); + + service.create(request); + + verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + verify(auth, never()).create(anyString(), any()); + } + + @Test + void relinkingExistingMembershipDoesNotDuplicateInitialProjection() { + when(repository.findByTenantAndAuthUser(0L, 10L)).thenReturn(membership); + CreateInternalUserRequest request = new CreateInternalUserRequest(10L, null, "平台运营", + "13800138000", null, null, null, "PLATFORM-30", List.of()); + + service.create(request); + + verifyNoInteractions(securityEvents); + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index c79c9b22f..fb4949914 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -12,6 +12,7 @@ import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -20,6 +21,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.LocalDateTime; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -31,6 +33,7 @@ class PlatformUserManagementServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient auth; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private PlatformUserManagementService service; private IamRole platformRole; private IamMembership membership; @@ -56,6 +59,7 @@ class PlatformUserManagementServiceTest { when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE", LocalDateTime.of(2026, 6, 1, 10, 30), 0L, 30L, "PLATFORM"))); + when(auth.revokeSessions(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); } @Test @@ -83,6 +87,30 @@ class PlatformUserManagementServiceTest { verify(auth, never()).create(anyString(), any()); } + @Test + void createPublishesActualStatusAndInitialAuthorizationAfterRolesAreStored() { + AtomicReference createdUser = new AtomicReference<>(); + doAnswer(invocation -> { createdUser.set(invocation.getArgument(0)); return null; }) + .when(members).insertUser(any(IamUser.class)); + when(members.findUser(anyLong())).thenAnswer(invocation -> createdUser.get()); + when(members.findRoleIds(eq(0L), anyLong())).thenReturn(List.of(40001L)); + when(auth.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary(eq("internal-token"), anyLong())).thenAnswer(invocation -> ApiResponse.success( + new AuthAccountClient.AccountSummary(invocation.getArgument(1), "newuser", "新用户", "DISABLED"))); + CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000", + "new@example.com", 0L, List.of(40001L), "Password1", "DISABLED"); + + service.create(request); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + long authUserId = account.getValue().accountId(); + verify(members).replaceRoles(eq(0L), anyLong(), eq(List.of(40001L))); + verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "DISABLED"); + verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + } + @Test void optionsContainOnlyPlatformTenantAndPlatformRoles() { UserOptions result = service.options(); @@ -116,6 +144,51 @@ class PlatformUserManagementServiceTest { verify(auth, never()).scope(anyString(), anyLong(), any()); } + @Test + void warehousePickerRequiresAndPersistsSingleOverseasWarehouseBinding() { + platformRole.setRoleCode("WAREHOUSE_PICKER"); + UpdateUserRequest missing = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), null); + assertThrows(BizException.class, + () -> service.update(30L, missing, Set.of("platform:user:grant-role"))); + + UpdateUserRequest bound = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), 900L); + UserResponse response = service.update(30L, bound, Set.of("platform:user:grant-role")); + + assertEquals(900L, membership.getOverseasWarehouseId()); + assertEquals(900L, response.overseasWarehouseId()); + verify(auth).revokeSessions(eq("internal-token"), any()); + } + + @Test + void changingWarehouseBindingRevokesAllAuthSessionsSoAccountVersionIsBumped() { + platformRole.setRoleCode("WAREHOUSE_PICKER"); + membership.setOverseasWarehouseId(900L); + UpdateUserRequest changed = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), 901L); + + UserResponse response = service.update(30L, changed, Set.of("platform:user:grant-role")); + + assertEquals(901L, response.overseasWarehouseId()); + verify(auth).revokeSessions(eq("internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); + } + + @Test + void clearingWarehouseBindingAlsoRevokesAllAuthSessions() { + platformRole.setRoleCode("WAREHOUSE_OPERATOR"); + membership.setOverseasWarehouseId(900L); + UpdateUserRequest cleared = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), null); + + UserResponse response = service.update(30L, cleared, Set.of("platform:user:grant-role")); + + assertNull(response.overseasWarehouseId()); + verify(auth).revokeSessions(eq("internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); + } + @Test void currentPlatformOperatorCannotDisableSelf() { assertThrows(BizException.class, diff --git a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java index 846166d9e..9854bc23e 100644 --- a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java @@ -40,6 +40,7 @@ class RbacServiceTest { @MockBean private RbacRepository repository; @MockBean private MemberRepository memberRepository; @MockBean private AuthAccountClient authClient; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private RbacService service; @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java new file mode 100644 index 000000000..aa212d14b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java @@ -0,0 +1,91 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +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.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.iam.client.SecurityEventBridgeClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityEventOutboxRelayTest { + @Mock private JdbcTemplate jdbc; + @Mock private SecurityEventBridgeClient client; + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private SecurityEventOutboxRelay relay; + + @BeforeEach + void setUp() { + relay = new SecurityEventOutboxRelay(); + ReflectionTestUtils.setField(relay, "jdbc", jdbc); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 12); + } + + @Test + void cnToOverseasEventIsSentOnlyAfterMatchingBridgeAcknowledgement() throws Exception { + EventEnvelope envelope = envelope(RegionCode.OVERSEAS); + when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); + when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) + .thenReturn(objectMapper.writeValueAsString(envelope)); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( + new SecurityEventBridgeClient.Acknowledgement(envelope.eventId(), "ACCEPTED", "2026-08-25T07:00:00Z"))); + + relay.deliver(envelope.eventId()); + + ArgumentCaptor sent = ArgumentCaptor.forClass(EventEnvelope.class); + verify(client).receive(eq("internal-token"), sent.capture()); + assertThat(sent.getValue().sourceRegion()).isEqualTo(RegionCode.CN); + assertThat(sent.getValue().targetRegion()).isEqualTo(RegionCode.OVERSEAS); + verify(jdbc).update(anyString(), any(), any(), eq(envelope.eventId()), anyString()); + } + + @Test + void nonOverseasTargetNeverReachesTheBridge() throws Exception { + EventEnvelope envelope = envelope(RegionCode.CN); + when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); + when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) + .thenReturn(objectMapper.writeValueAsString(envelope)); + when(jdbc.queryForObject(anyString(), eq(Integer.class), eq(envelope.eventId()))).thenReturn(0); + + relay.deliver(envelope.eventId()); + + verify(client, never()).receive(anyString(), any(EventEnvelope.class)); + verify(jdbc).update(anyString(), eq("PENDING"), eq(1), any(), anyString(), any(), + eq(envelope.eventId()), anyString()); + } + + private EventEnvelope envelope(RegionCode target) { + var value = new IamSecurityEventsV1.PermissionVersionChanged( + 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z")); + var payload = objectMapper.valueToTree(value); + return new EventEnvelope("00000000-0000-4000-8000-000000000081", + EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, + Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, target, "0", + "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", + null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java new file mode 100644 index 000000000..fb1ce9e8b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java @@ -0,0 +1,74 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityEventOutboxServiceTest { + @Mock JdbcTemplate jdbc; + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private SecurityEventOutboxService service; + + @BeforeEach + void setUp() { + service = new SecurityEventOutboxService(); + ReflectionTestUtils.setField(service, "jdbc", jdbc); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + } + + @Test + void userStatusWritesAValidEnvelopeWithCorrelationId() throws Exception { + when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(1L); + + service.userStatus(101L, 0L, "PLATFORM", "DISABLED"); + + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); + EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); + assertThat(envelope.requireValid().correlationId()).isNotBlank().isEqualTo(envelope.traceId()); + } + + @ParameterizedTest + @CsvSource({ + "ACTIVE,ACTIVE", + "PROFILE_REQUIRED,DISABLED", + "PENDING,DISABLED", + "REJECTED,DISABLED", + "DISABLED,DISABLED", + "ARCHIVED,DISABLED" + }) + void tenantWorkflowStatusIsNormalizedToTheSecurityContract(String businessStatus, + String expectedSecurityStatus) throws Exception { + when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(3L); + + service.tenantStatus(10001L, businessStatus); + + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); + EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); + IamSecurityEventsV1.TenantStatusChanged payload = objectMapper.treeToValue( + envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); + assertThat(payload.status()).isEqualTo(expectedSecurityStatus); + assertThat(envelope.aggregateVersion()).isEqualTo(3L); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java index 8dfbe466c..8e76d8a1d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -34,6 +34,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @@ -43,6 +44,7 @@ class TenantAdministratorProvisioningServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient auth; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private TenantAdministratorProvisioningService service; private IamTenant tenant; @@ -101,6 +103,9 @@ class TenantAdministratorProvisioningServiceTest { verify(members).insertUser(any(IamUser.class)); verify(members).insert(any(IamMembership.class)); verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L))); + verify(securityEvents).userStatus(account.getValue().accountId(), tenant.getId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(account.getValue().accountId(), tenant.getId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); } @Test @@ -135,6 +140,7 @@ class TenantAdministratorProvisioningServiceTest { assertEquals(null, result.temporaryPassword()); verify(auth, never()).create(anyString(), any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); + verifyNoInteractions(securityEvents); } @Test @@ -153,6 +159,8 @@ class TenantAdministratorProvisioningServiceTest { assertFalse(result.created()); verify(auth, never()).create(anyString(), any()); verify(members).replaceRoles(tenant.getId(), owner.getId(), List.of(4000200L, 4000100L)); + verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "MEMBER_ROLES_CHANGED"); } @Test @@ -190,6 +198,9 @@ class TenantAdministratorProvisioningServiceTest { verify(members, never()).insertUser(any()); verify(members, never()).insert(any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); + verify(securityEvents).userStatus(user.getAuthUserId(), tenant.getId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java index 3c805ff66..6ff844faa 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -28,6 +28,7 @@ class TenantCustomerExportTest { @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @MockBean private TenantAdministratorProvisioningService administratorProvisioning; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private TenantService service; @Test void returnsUnmaskedCustomerRowsAndAdvancingCursor() { diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 2d99dd94a..eb7bb54dc 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -54,6 +54,7 @@ class TenantServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient authClient; + @MockBean private SecurityEventOutboxService securityEvents; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @SpyBean private TenantAdministratorProvisioningService provisioning; @@ -312,6 +313,7 @@ class TenantServiceTest { 9001L); assertEquals("PROFILE_REQUIRED", created.tenant().status()); + assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("Bob", created.tenant().legalRepresentative()); assertEquals("基础版", created.tenant().servicePackage()); assertEquals("13800138000", created.administratorUsername()); @@ -319,12 +321,13 @@ class TenantServiceTest { verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L); + verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); verify(repository, never()).insertReview(any()); } @Test - void platformRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); @@ -427,6 +430,7 @@ class TenantServiceTest { assertEquals("ACTIVE", tenant.getStatus()); verify(repository).saveReview(review); verify(repository).saveTenant(tenant); + verify(securityEvents).tenantStatus(10001L, "ACTIVE"); } @Test @@ -488,12 +492,26 @@ class TenantServiceTest { assertEquals(true, withQualification.eligible()); } + @Test + void activeCustomerCannotOrderUntilDefaultDimensionsAreReady() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + tenant.setDimensionInitializationStatus("FAILED"); + when(repository.findTenant(10001L)).thenReturn(tenant); + + var result = service.orderEligibility(10001L); + + assertEquals(false, result.eligible()); + assertEquals("Customer default dimensions are not ready", result.reason()); + verify(profileRepository, never()).findQualifications(10001L); + } + private IamTenant merchant(long id, String status) { IamTenant tenant = new IamTenant(); tenant.setId(id); tenant.setTenantCode("TEN" + id); tenant.setTenantType("MERCHANT"); tenant.setStatus(status); + tenant.setDimensionInitializationStatus("READY"); tenant.setTenantName("Example Merchant"); tenant.setLegalName("Example Merchant Ltd"); tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); diff --git a/iam/src/test/resources/db/verify-phase1-admin-rbac.sql b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql index e2942472f..24dce2749 100644 --- a/iam/src/test/resources/db/verify-phase1-admin-rbac.sql +++ b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql @@ -1,13 +1,17 @@ -- Any returned row represents a failed phase-one seed assertion. SELECT 'missing_auth_accounts' AS failure WHERE (SELECT COUNT(*) FROM baiyue_auth.auth_account - WHERE username IN ('platform_admin', 'merchant_admin') AND account_status = 'ACTIVE') <> 2 + WHERE username = 'platform_admin' AND account_status = 'ACTIVE') <> 1 UNION ALL SELECT 'missing_active_identities' WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_user u JOIN baiyue_iam.iam_membership m ON m.user_id = u.id - WHERE u.auth_user_id IN (1000001, 1000002) - AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 2 + WHERE u.auth_user_id = 1000001 + AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 1 +UNION ALL +SELECT 'unexpected_fixed_customer_seed' +WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_tenant WHERE tenant_code = 'IT_TEST') + OR EXISTS (SELECT 1 FROM baiyue_auth.auth_account WHERE username = 'merchant_admin') UNION ALL SELECT 'unexpected_permission_type' WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_permission @@ -34,10 +38,6 @@ SELECT 'platform_admin_not_fully_granted' WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000001) <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type IN ('MENU', 'BUTTON')) UNION ALL -SELECT 'merchant_owner_not_fully_granted' -WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000101) - <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 10001 AND app_code = 'MERCHANT' AND permission_type IN ('MENU', 'BUTTON')) -UNION ALL SELECT 'missing_global_merchant_owner_template' WHERE NOT EXISTS ( SELECT 1 FROM baiyue_iam.iam_role diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index b7e48949b..232328dc0 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责百度 OCR、Amazon Logistics/SP-API 等第三方适配和能力开关;不保存业务主数据。 +- 负责百度 OCR、Amazon Logistics/SP-API、四期船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。 - 二期重量 OCR 由 `file/` 调用;本服务只返回识别结果,不保存 `file_object`、入库单或重量确认记录。 ## 模块入口与验证 @@ -11,11 +11,13 @@ - 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java` - OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java` - 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java` +- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/四期/船节点标准化契约.md` - 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml` - 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test` ## 修改约束与分期 - 密钥、原始响应和客户敏感数据不得写入日志;真实调用默认关闭,适配器负责超时、重试、限流、错误归一化。 +- 船节点适配器不得自行维护业务轮询任务或到港状态;必须返回可审计的来源、观测时间、标准化节点和错误结果,由 Firstleg 负责幂等落库与停止条件。查询周期和供应商额度通过配置表达,不硬编码复用三期人工查询限制。 - OCR 只能访问受控输入/输出目录,接口仅接受内部服务令牌;契约变更同步检查 File 的 `WeightOcrClient` 和测试。 -- 全量验收按业务链路核对通用适配、File 重量 OCR、船期外部查询及已确认的报关协作契约;外部调用必须保留来源、频率、限流、超时、最近有效结果和过期标识,凭证缺失按既定人工修正降级,不把未确认的自动化能力当作已实现契约。相关依据:`../../docs/一期/`、`../../docs/二期/`、`../../docs/三期/`。 +- 全量验收按业务链路核对通用适配、File 重量 OCR 及已确认的报关协作契约;柜子确认国内出仓后的船司/船节点外部查询整体归四期,不再列作三期能力。外部调用必须保留来源、频率、限流、超时、最近有效结果和过期标识,凭证缺失按既定人工修正降级,不把未确认的自动化能力当作已实现契约。相关依据:`../../docs/一期/`、`../../docs/二期/`、`../../docs/三期/`、`../../docs/四期/`。 diff --git a/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java index 7ad1b09aa..0a4f88a6c 100644 --- a/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java +++ b/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java @@ -8,7 +8,8 @@ import java.nio.file.Path; import java.time.Duration; @ConfigurationProperties(prefix = "baiyue.integration") -public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi) { +public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi, + CarrierSchedule carrierSchedule) { public record BaiduOcr(boolean enabled, String apiKey, String secretKey, @@ -84,6 +85,136 @@ public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi) } } + public record CarrierSchedule(Matson matson, Hede hede, Sml sml, Zim zim, Evergreen evergreen) { + public record Matson(boolean enabled, + URI pageUrl, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { + return enabled && pageUrl != null && queryUrl != null; + } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Hede(boolean enabled, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { return enabled && queryUrl != null; } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Sml(boolean enabled, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { return enabled && queryUrl != null; } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Zim(boolean enabled, + URI authorizationUrl, + URI tracingBaseUrl, + String clientId, + String clientSecret, + String subscriptionKey, + Duration connectTimeout, + Duration requestTimeout, + int maxConcurrency) { + public boolean configured() { + return enabled && authorizationUrl != null && tracingBaseUrl != null + && hasText(clientId) && hasText(clientSecret) && hasText(subscriptionKey); + } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public int effectiveMaxConcurrency() { return Math.max(1, Math.min(10, maxConcurrency)); } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Evergreen(boolean enabled, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { return enabled && queryUrl != null; } + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + } + private static boolean hasText(String value) { return value != null && !value.isBlank(); } diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java b/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java index 6205eac7b..a2a279d03 100644 --- a/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java +++ b/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java @@ -4,10 +4,16 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.integration.dto.ProviderStatus; import com.baiyue.integration.security.InternalServiceTokenGuard; import com.baiyue.integration.service.ProviderStatusService; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.service.CarrierScheduleProviderService; +import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -19,6 +25,8 @@ public class IntegrationProviderController { private ProviderStatusService providerStatusService; @Resource private InternalServiceTokenGuard internalServiceTokenGuard; + @Resource + private CarrierScheduleProviderService carrierScheduleProviderService; @GetMapping("/providers") public ApiResponse> providers( @@ -26,4 +34,12 @@ public class IntegrationProviderController { internalServiceTokenGuard.verify(token); return ApiResponse.success(providerStatusService.providers()); } + + @PostMapping("/carrier-schedules/query") + public ApiResponse queryCarrierSchedule( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody QueryRequest request) { + internalServiceTokenGuard.verify(token); + return ApiResponse.success(carrierScheduleProviderService.query(request)); + } } diff --git a/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java new file mode 100644 index 000000000..f8e956b08 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java @@ -0,0 +1,15 @@ +package com.baiyue.integration.domain; + +import java.util.Locale; + +public enum CarrierScheduleExceptionCode { + CUSTOMS_HOLD, + TERMINAL_HOLD, + DOCUMENT_HOLD, + UNKNOWN_HOLD; + + public static boolean supports(String value) { + try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; } + catch (IllegalArgumentException exception) { return false; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java new file mode 100644 index 000000000..8edc347d9 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java @@ -0,0 +1,21 @@ +package com.baiyue.integration.domain; + +import java.util.Locale; + +public enum CarrierScheduleStandardNode { + DEPARTED_WAREHOUSE, + AT_ORIGIN_PORT, + LOADED_ON_VESSEL, + DEPARTED_ORIGIN_PORT, + IN_TRANSIT, + ARRIVED_DESTINATION_PORT, + DISCHARGED_FROM_VESSEL, + PICKUP_READY, + PICKED_UP, + EMPTY_RETURNED; + + public static boolean supports(String value) { + try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; } + catch (IllegalArgumentException exception) { return false; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java new file mode 100644 index 000000000..7692a0919 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java @@ -0,0 +1,29 @@ +package com.baiyue.integration.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class CarrierScheduleDtos { + private CarrierScheduleDtos() { } + + public record QueryRequest(@NotBlank @Size(max = 16) String carrierCode, + @NotBlank @Size(max = 32) String trackingType, + @NotBlank @Size(max = 128) String trackingNo) { } + + public record QueryResult(String providerCode, String outcome, List observations, + List exceptions, + String errorCode, String errorMessage, LocalDateTime providerObservedAt) { } + + public record Observation(String providerEventId, String providerNodeCode, String standardNode, + LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, + String locationCode, String locationName, String sourceReference, + String rawPayloadHash) { } + + public record ExceptionObservation(String providerEventId, String providerStatus, + String exceptionCode, boolean active, LocalDateTime observedAt, + String locationCode, String locationName, String sourceReference, + String rawPayloadHash) { } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/CarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/CarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..7987204e0 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/CarrierScheduleProviderAdapter.java @@ -0,0 +1,9 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; + +public interface CarrierScheduleProviderAdapter { + boolean supports(String carrierCode); + QueryResult query(QueryRequest request); +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..d3f4db74a --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java @@ -0,0 +1,38 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import org.springframework.stereotype.Component; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Safe fallback for the six confirmed carriers. It never scrapes captcha/dynamic pages and returns + * an explicit provider-unavailable outcome until a documented provider API adapter is configured. + */ +@Component +@Order(Ordered.LOWEST_PRECEDENCE) +public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final Set CARRIERS = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE"); + + @Override + public boolean supports(String carrierCode) { + return carrierCode != null && CARRIERS.contains(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(), List.of(), + "PROVIDER_API_NOT_CONFIGURED", "未配置可审计的结构化轨迹供应商接口,不执行网页抓取", + LocalDateTime.now()); + } + + @Override + public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE_FALLBACK", true, false); } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..f92681d06 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java @@ -0,0 +1,206 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.EvergreenTrackingClient.EvergreenTrackingException; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.util.HtmlUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +@Order(8) +public class EvergreenCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "EVERGREEN_PUBLIC_HTML"; + private static final String LOADED = "LOADED (FCL) ON VESSEL"; + private static final DateTimeFormatter DAY = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendPattern("MMM-dd-uuuu") + .toFormatter(Locale.ENGLISH); + private static final Pattern TABLE = Pattern.compile("(?is)]*>(.*?)"); + private static final Pattern ROW = Pattern.compile("(?is)]*>(.*?)"); + private static final Pattern CELL = Pattern.compile("(?is)]*>(.*?)"); + private static final Pattern COUNTRY_CODE = Pattern.compile("\\(([A-Z]{2})\\)\\s*$"); + private static final Set REQUIRED_HEADERS = Set.of("箱号", "柜型", "日期", "货柜动态", "地点", "船名航次", "METHOD", "VGM"); + private final EvergreenTrackingClient client; + + public EvergreenCarrierScheduleProviderAdapter(EvergreenTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "EMC".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + try { + EvergreenTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response, queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, + List.of(), null, null, queriedAt); + } catch (EvergreenTrackingException exception) { + return new QueryResult(PROVIDER, exception.outcome(), List.of(), List.of(), exception.code(), + exception.getMessage(), queriedAt); + } catch (EvergreenParseException exception) { + return new QueryResult(PROVIDER, "MANUAL_REQUIRED", List.of(), List.of(), exception.code, + exception.getMessage(), queriedAt); + } + } + + List observations(String containerNo, EvergreenTrackingClient.TrackingResponse response, + LocalDateTime queriedAt) { + if (response == null) return List.of(); + String container = normalize(containerNo); + TrackingRow row = trackingRow(response.containerHtml(), container); + if (row == null) throw new EvergreenParseException("EMC_CONTAINER_ROW_NOT_FOUND", "长荣 HTML 未找到目标柜行"); + String rawHash = sha256(response.rawBody() == null + ? safe(response.containerHtml()) + safe(response.billHtml()) : response.rawBody()); + List facts = new ArrayList<>(); + if (LOADED.equals(normalizeText(row.status()))) { + LocalDate date = parseDay(row.date()); + if (date == null) throw new EvergreenParseException("EMC_DAY_PARSE_FAILED", "长荣货柜动态日期无法解析"); + add(facts, container, "LOADED_FCL_ON_VESSEL", "LOADED_ON_VESSEL", queriedAt, date, + false, row.location(), row.vesselVoyage(), response.billSuffix(), rawHash); + } + String estimatedRaw = estimatedLoadingDate(response.billHtml()); + LocalDate estimated = parseDay(estimatedRaw); + if (estimated != null) { + add(facts, container, "ESTIMATED_LOADING_DATE", "LOADED_ON_VESSEL", queriedAt, estimated, + true, row.location(), row.vesselVoyage(), response.billSuffix(), rawHash); + } + facts.sort(Comparator.comparing(value -> value.actualAt() == null ? value.estimatedAt() : value.actualAt())); + return List.copyOf(facts); + } + + static String standardNode(String status) { + return LOADED.equals(normalizeText(status)) ? "LOADED_ON_VESSEL" : null; + } + + private static void add(List facts, String container, String providerNode, String standardNode, + LocalDateTime queriedAt, LocalDate day, boolean estimated, String location, + String vesselVoyage, String billSuffix, String rawHash) { + LocalDateTime eventAt = day.atStartOfDay(); + String rawDate = day.format(DAY).toUpperCase(Locale.ENGLISH); + String reference = "EMC:CNTR:" + container + "|BL_SUFFIX:" + safe(billSuffix) + + "|DATE:" + rawDate + "|PRECISION:DAY|ZONE:Asia/Shanghai"; + String eventId = sha256(container + '|' + providerNode + '|' + rawDate + '|' + + normalizeText(location) + '|' + normalizeText(vesselVoyage)); + facts.add(new Observation(eventId, providerNode, standardNode, queriedAt, + estimated ? eventAt : null, estimated ? null : eventAt, countryCode(location), blank(location), + reference, rawHash)); + } + + static TrackingRow trackingRow(String html, String targetContainer) { + Matcher tables = TABLE.matcher(html == null ? "" : html); + while (tables.find()) { + List> rows = rows(tables.group(1)); + for (int headerIndex = 0; headerIndex < rows.size(); headerIndex++) { + Map header = header(rows.get(headerIndex)); + if (!header.keySet().containsAll(REQUIRED_HEADERS)) continue; + for (int index = headerIndex + 1; index < rows.size(); index++) { + List cells = rows.get(index); + String container = cell(cells, header.get("箱号")); + if (normalize(container).equals(targetContainer)) { + return new TrackingRow(container, cell(cells, header.get("日期")), + cell(cells, header.get("货柜动态")), cell(cells, header.get("地点")), + cell(cells, header.get("船名航次"))); + } + } + } + } + return null; + } + + static String estimatedLoadingDate(String html) { + for (List row : allRows(html)) { + for (int index = 0; index + 1 < row.size(); index++) { + String label = row.get(index).replaceAll("\\s+", ""); + if ("预计装船日".equals(label) || "預計裝船日".equals(label)) return row.get(index + 1).trim(); + } + } + return null; + } + + private static List> allRows(String html) { + List> values = new ArrayList<>(); Matcher tables = TABLE.matcher(html == null ? "" : html); + while (tables.find()) values.addAll(rows(tables.group(1))); return values; + } + private static List> rows(String table) { + List> values = new ArrayList<>(); Matcher rows = ROW.matcher(table); + while (rows.find()) { + List cells = new ArrayList<>(); Matcher cell = CELL.matcher(rows.group(1)); + while (cell.find()) cells.add(htmlText(cell.group(1))); if (!cells.isEmpty()) values.add(List.copyOf(cells)); + } + return values; + } + private static Map header(List cells) { + Map values = new HashMap<>(); + for (int index = 0; index < cells.size(); index++) values.put(normalizeHeader(cells.get(index)), index); + return values; + } + private static String normalizeHeader(String value) { + String text = value == null ? "" : value.replaceAll("\\s+", "").trim(); + return text.chars().allMatch(ch -> ch < 128) ? text.toUpperCase(Locale.ROOT) : text; + } + private static String cell(List cells, Integer index) { + return index == null || index < 0 || index >= cells.size() ? "" : cells.get(index).trim(); + } + private static String htmlText(String value) { + return HtmlUtils.htmlUnescape(value.replaceAll("(?is)<[^>]+>", " ")).replace('\u00a0', ' ') + .replaceAll("\\s+", " ").trim(); + } + private static LocalDate parseDay(String value) { + if (value == null || value.isBlank()) return null; + try { return LocalDate.parse(value.trim(), DAY); } + catch (DateTimeParseException exception) { return null; } + } + private static String countryCode(String location) { + Matcher matcher = COUNTRY_CODE.matcher(location == null ? "" : location.trim().toUpperCase(Locale.ROOT)); + return matcher.find() ? matcher.group(1) : null; + } + private static String normalizeText(String value) { + return value == null ? "" : HtmlUtils.htmlUnescape(value).trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT); + } + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String safe(String value) { return value == null ? "" : value; } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + record TrackingRow(String containerNo, String date, String status, String location, String vesselVoyage) { } + private static final class EvergreenParseException extends RuntimeException { + private final String code; + private EvergreenParseException(String code, String message) { super(message); this.code = code; } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_EMC", client.configured(), client.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java new file mode 100644 index 000000000..30eed87f4 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java @@ -0,0 +1,151 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; +import org.springframework.web.util.HtmlUtils; + +import java.io.IOException; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class EvergreenTrackingClient { + private static final Pattern INPUT = Pattern.compile("(?is)]*>"); + private static final Pattern ATTRIBUTE = Pattern.compile("(?i)\\b([a-z_:][-a-z0-9_:.]*)\\s*=\\s*([\"'])(.*?)\\2"); + private static final Pattern DISP_INFO = Pattern.compile("(?is)GetDispInfo[^0-9]{0,160}(\\d{12})"); + private static final Pattern BL_QUERY = Pattern.compile("(?i)(?:[?&]|&)(?:BL|bl_no)=(\\d{12})"); + private final Object requestLock = new Object(); + @Resource private IntegrationProperties properties; + private IntegrationProperties.CarrierSchedule.Evergreen config; + private HttpClient httpClient; + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().evergreen(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL).build(); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) manual("EMC_NOT_CONFIGURED", "长荣公开货柜动态查询未启用"); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + manual("EMC_CONTAINER_REQUIRED", "长荣首批自动节点仅支持柜号查询"); + } + synchronized (requestLock) { + waitForSlot(); + try { + String container = normalize(request.trackingNo()); + String containerHtml = post("TYPE=CNTR&CNTR=" + encode(container), "CONTAINER"); + String bill = relatedBill(containerHtml); + if (bill == null) manual("EMC_RELATED_BL_MISSING", "长荣柜号结果缺少关联提单,需人工复核"); + String billHtml = post("TYPE=BL&BL=" + encode(bill), "BILL"); + return new TrackingResponse(containerHtml, billHtml, bill.substring(bill.length() - 4), + containerHtml + billHtml); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private String post(String form, String stage) { + try { + HttpResponse response = httpClient.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()).header("Accept", "text/html") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() == 429 || response.statusCode() >= 500) { + throw retryable("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询暂时不可用", null); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + manual("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询拒绝请求"); + } + if (!looksLikeHtml(response.body())) manual("EMC_" + stage + "_NON_HTML", "长荣返回非 HTML 内容"); + return response.body(); + } catch (EvergreenTrackingException exception) { + throw exception; + } catch (HttpTimeoutException exception) { + throw retryable("EMC_" + stage + "_TIMEOUT", "长荣公开查询超时", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw retryable("EMC_" + stage + "_INTERRUPTED", "长荣公开查询被中断", exception); + } catch (IOException exception) { + throw retryable("EMC_" + stage + "_IO_FAILED", "长荣公开查询网络失败", exception); + } + } + + static String relatedBill(String html) { + Matcher inputs = INPUT.matcher(html == null ? "" : html); + while (inputs.find()) { + String name = attribute(inputs.group(), "name"); + String value = HtmlUtils.htmlUnescape(attribute(inputs.group(), "value")); + if ("bl_no".equalsIgnoreCase(name) && value.matches("\\d{12}")) return value; + } + String decoded = HtmlUtils.htmlUnescape(html == null ? "" : html); + Matcher action = DISP_INFO.matcher(decoded); + if (action.find()) return action.group(1); + Matcher query = BL_QUERY.matcher(decoded); + return query.find() ? query.group(1) : null; + } + + private static String attribute(String tag, String expected) { + Matcher attributes = ATTRIBUTE.matcher(tag); + while (attributes.find()) if (expected.equalsIgnoreCase(attributes.group(1))) return attributes.group(3).trim(); + return ""; + } + private static boolean looksLikeHtml(String value) { + String lower = value == null ? "" : value.toLowerCase(Locale.ROOT); + return lower.contains("]"; + } + } + public static final class EvergreenTrackingException extends RuntimeException { + private final String outcome; private final String code; + public EvergreenTrackingException(String outcome, String code, String message) { + super(message); this.outcome = outcome; this.code = code; + } + public EvergreenTrackingException(String outcome, String code, String message, Throwable cause) { + super(message, cause); this.outcome = outcome; this.code = code; + } + public String outcome() { return outcome; } + public String code() { return code; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..4206d504b --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapter.java @@ -0,0 +1,161 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.HedeTrackingClient.HedeTrackingException; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +@Order(10) +public class HedeCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "HEDE_PUBLIC_HTML"; + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final Pattern ROW_PATTERN = Pattern.compile( + "]*class=[\\\"'][^\\\"']*read-tr[^\\\"']*[\\\"'][^>]*>([\\s\\S]*?)", + Pattern.CASE_INSENSITIVE); + private static final Pattern CELL_PATTERN = Pattern.compile("]*>([\\s\\S]*?)", + Pattern.CASE_INSENSITIVE); + private final HedeTrackingClient client; + + public HedeCarrierScheduleProviderAdapter(HedeTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "HEDE".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + return new QueryResult(PROVIDER, "PERMANENT_FAILURE", List.of(), List.of(), "HEDE_CONTAINER_REQUIRED", + "合德自动节点当前仅支持按柜号精确解析,提单可能返回多个柜子", queriedAt); + } + try { + HedeTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response.html(), queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, List.of(), + null, null, queriedAt); + } catch (HedeTrackingException exception) { + return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE", + List.of(), List.of(), exception.code(), exception.getMessage(), queriedAt); + } catch (Exception exception) { + return new QueryResult(PROVIDER, "PERMANENT_FAILURE", List.of(), List.of(), "HEDE_HTML_SCHEMA_CHANGED", + "合德查询 HTML 结构无法解析", queriedAt); + } + } + + List observations(String containerNo, String html) throws Exception { + return observations(containerNo, html, LocalDateTime.now(ZoneOffset.UTC)); + } + + private List observations(String containerNo, String html, LocalDateTime queriedAt) throws Exception { + String normalizedContainer = normalizeIdentifier(containerNo); + List matched = rows(html).stream() + .filter(value -> normalizeIdentifier(value.containerNo()).equals(normalizedContainer)).toList(); + if (matched.isEmpty()) return List.of(); + String rawHash = sha256(html); + List values = new ArrayList<>(); + for (HedeRow row : matched) { + String reference = "HEDE:" + row.containerNo() + ':' + row.vessel() + '/' + row.voyage(); + addEstimated(values, row, "ESTIMATED_DEPARTURE", "DEPARTED_ORIGIN_PORT", + row.estimatedDeparture(), queriedAt, reference, rawHash); + addEstimated(values, row, "ESTIMATED_ARRIVAL", "ARRIVED_DESTINATION_PORT", + row.estimatedArrival(), queriedAt, reference, rawHash); + addActual(values, row, "FULL_GATE_IN", "AT_ORIGIN_PORT", row.fullGateIn(), queriedAt, reference, rawHash); + addActual(values, row, "LOADED", "LOADED_ON_VESSEL", row.loadedAt(), queriedAt, reference, rawHash); + addActual(values, row, "DISCHARGED", "DISCHARGED_FROM_VESSEL", row.dischargedAt(), queriedAt, reference, rawHash); + addActual(values, row, "FULL_GATE_OUT", "PICKED_UP", row.fullGateOut(), queriedAt, reference, rawHash); + addActual(values, row, "EMPTY_RETURNED", "EMPTY_RETURNED", row.emptyReturnedAt(), queriedAt, reference, rawHash); + } + values.sort(Comparator.comparing(Observation::observedAt)); + return List.copyOf(values); + } + + private static void addActual(List values, HedeRow row, String providerNode, + String standardNode, String timestamp, LocalDateTime queriedAt, + String reference, String rawHash) { + LocalDateTime actualAt = parseDate(timestamp); + if (actualAt == null) return; + String eventIdentity = row.containerNo() + '|' + providerNode + '|' + timestamp; + values.add(new Observation(sha256(eventIdentity), providerNode, standardNode, queriedAt, + null, actualAt, null, null, reference, rawHash)); + } + + private static void addEstimated(List values, HedeRow row, String providerNode, + String standardNode, String timestamp, LocalDateTime queriedAt, + String reference, String rawHash) { + LocalDateTime estimatedAt = parseDate(timestamp); + if (estimatedAt == null) return; + String eventIdentity = row.containerNo() + '|' + providerNode + '|' + timestamp; + values.add(new Observation(sha256(eventIdentity), providerNode, standardNode, queriedAt, + estimatedAt, null, null, null, reference, rawHash)); + } + + static List rows(String html) { + List rows = new ArrayList<>(); + Matcher rowMatcher = ROW_PATTERN.matcher(html == null ? "" : html); + while (rowMatcher.find()) { + List cells = new ArrayList<>(); + Matcher cellMatcher = CELL_PATTERN.matcher(rowMatcher.group(1)); + while (cellMatcher.find()) cells.add(htmlText(cellMatcher.group(1))); + if (cells.size() >= 12) rows.add(row(cells)); + } + return List.copyOf(rows); + } + + private static String htmlText(String value) { + return value.replaceAll("(?is)<[^>]+>", " ") + .replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'").trim().replaceAll("\\s+", " "); + } + + private static HedeRow row(List cells) { + return new HedeRow(cells.get(0), cells.get(1), cells.get(2), cells.get(3), cells.get(4), cells.get(5), + cells.get(6), cells.get(7), cells.get(8), cells.get(9), cells.get(10), cells.get(11)); + } + + private static LocalDateTime parseDate(String value) { + if (value == null || value.isBlank()) return null; + try { return LocalDateTime.parse(value.trim(), DATE_TIME); } + catch (DateTimeParseException exception) { return null; } + } + + private static String normalizeIdentifier(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { throw new IllegalStateException(exception); } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_HEDE", client.configured(), client.configured()); + } + + record HedeRow(String billNo, String containerNo, String vessel, String voyage, + String estimatedDeparture, String estimatedArrival, String emptyGateOut, + String fullGateIn, String loadedAt, String dischargedAt, + String fullGateOut, String emptyReturnedAt) { } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java new file mode 100644 index 000000000..e223970f2 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java @@ -0,0 +1,123 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +@Component +public class HedeTrackingClient { + private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + + @Resource private IntegrationProperties properties; + private IntegrationProperties.CarrierSchedule.Hede config; + private HttpClient httpClient; + private final Object requestLock = new Object(); + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().hede(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? java.time.Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) { + throw new HedeTrackingException("HEDE_NOT_CONFIGURED", false, "合德网页查询能力未启用或地址未配置"); + } + synchronized (requestLock) { + waitForRequestSlot(); + try { + return execute(request); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private TrackingResponse execute(QueryRequest request) { + String trackingType = request.trackingType().trim().toUpperCase(Locale.ROOT); + String billNo = "BILL_OF_LADING".equals(trackingType) ? request.trackingNo().trim() : ""; + String containerNo = "CONTAINER_NO".equals(trackingType) ? request.trackingNo().trim() : ""; + if (billNo.isEmpty() && containerNo.isEmpty()) { + throw new HedeTrackingException("HEDE_TRACKING_TYPE_UNSUPPORTED", false, + "合德仅支持提单号或柜号查询"); + } + String form = "billno=" + encode(billNo) + "&cntr=" + encode(containerNo) + + "&selecttype=1&resultlist=1¶mtype=0"; + try { + HttpResponse response = httpClient.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureHttp(response.statusCode()); + return new TrackingResponse(response.body()); + } catch (HedeTrackingException exception) { + throw exception; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new HedeTrackingException("HEDE_INTERRUPTED", true, "合德查询被中断", exception); + } catch (IOException exception) { + throw new HedeTrackingException("HEDE_IO_FAILED", true, "合德查询网络失败", exception); + } + } + + private void waitForRequestSlot() { + long waitMillis = nextRequestAtMillis - System.currentTimeMillis(); + if (waitMillis <= 0) return; + try { + Thread.sleep(waitMillis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new HedeTrackingException("HEDE_INTERRUPTED", true, "等待合德查询限流窗口时被中断", exception); + } + } + + private static void ensureHttp(int status) { + if (status >= 200 && status < 300) return; + if (status == 429) { + throw new HedeTrackingException("HEDE_RATE_LIMITED", true, "合德查询触发访问频率限制"); + } + throw new HedeTrackingException("HEDE_HTTP_" + status, status >= 500, + "合德查询返回异常 HTTP 状态:" + status); + } + + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } + + public record TrackingResponse(String html) { } + + public static final class HedeTrackingException extends RuntimeException { + private final String code; + private final boolean retryable; + + public HedeTrackingException(String code, boolean retryable, String message) { + super(message); this.code = code; this.retryable = retryable; + } + + public HedeTrackingException(String code, boolean retryable, String message, Throwable cause) { + super(message, cause); this.code = code; this.retryable = retryable; + } + + public String code() { return code; } + public boolean retryable() { return retryable; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..c78f41108 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapter.java @@ -0,0 +1,220 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.MatsonTrackingClient.MatsonTrackingException; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; + +@Component +@Order(0) +public class MatsonCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "MATSON_PUBLIC_TRACKING"; + private final MatsonTrackingClient client; + + public MatsonCarrierScheduleProviderAdapter(MatsonTrackingClient client) { + this.client = client; + } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "MATSON".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + try { + MatsonTrackingClient.TrackingResponse response = client.query(request); + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + List observations = observations(request, response.payload(), response.rawBody(), queriedAt); + List exceptions = exceptions(request, response.payload(), response.rawBody(), queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() && exceptions.isEmpty() ? "NO_CHANGE" : "SUCCESS", + observations, exceptions, + null, null, queriedAt); + } catch (MatsonTrackingException exception) { + return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE", + List.of(), List.of(), exception.code(), exception.getMessage(), LocalDateTime.now(ZoneOffset.UTC)); + } + } + + List observations(QueryRequest request, JsonNode payload, String rawBody, + LocalDateTime queriedAt) { + if (payload == null || !payload.isObject()) return List.of(); + if ("CONTAINER_NO".equalsIgnoreCase(request.trackingType()) && !matchesContainer(request.trackingNo(), payload)) { + return List.of(); + } + String rawHash = sha256(rawBody == null ? payload.toString() : rawBody); + String sourceReference = "MATSON:" + text(payload, "bookingNumber", request.trackingNo()); + List values = new ArrayList<>(); + JsonNode events = payload.path("events"); + if (events.isArray()) for (JsonNode event : events) { + long epochMillis = event.path("date").asLong(0); + String rawStatus = text(event, "status", "").trim(); + String standardNode = standardNode(rawStatus); + if (epochMillis <= 0 || standardNode == null) continue; + LocalDateTime actualAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC); + String locationName = text(event, "location", "").trim(); + String locationCode = locationCode(payload, locationName, standardNode); + String eventIdentity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + '|' + epochMillis + '|' + + normalizeStatus(rawStatus) + '|' + locationName.toUpperCase(Locale.ROOT); + values.add(new Observation(sha256(eventIdentity), truncate(normalizeStatus(rawStatus), 64), standardNode, + queriedAt, null, actualAt, blankToNull(locationCode), blankToNull(locationName), + sourceReference, rawHash)); + } + LocalDateTime eta = parseDate(payload.get("vesselETA")); + if (eta != null) { + String eventIdentity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + "|VESSEL_ETA|" + eta; + values.add(new Observation(sha256(eventIdentity), "VESSEL_ETA", "ARRIVED_DESTINATION_PORT", + queriedAt, eta, null, blankToNull(text(payload, "destCode", "")), + blankToNull(text(payload, "destPort", "")), sourceReference, rawHash)); + } + values.sort(Comparator.comparing(Observation::observedAt)); + return List.copyOf(values); + } + + List exceptions(QueryRequest request, JsonNode payload, String rawBody, + LocalDateTime queriedAt) { + if (payload == null || !payload.isObject()) return List.of(); + if ("CONTAINER_NO".equalsIgnoreCase(request.trackingType()) && !matchesContainer(request.trackingNo(), payload)) { + return List.of(); + } + String rawHash = sha256(rawBody == null ? payload.toString() : rawBody); + String sourceReference = "MATSON:" + text(payload, "bookingNumber", request.trackingNo()); + List values = new ArrayList<>(); + JsonNode events = payload.path("events"); + if (!events.isArray()) return List.of(); + for (JsonNode event : events) { + String rawStatus = text(event, "status", "").trim(); + String status = normalizeStatus(rawStatus); + String exceptionCode = exceptionCode(status); + long epochMillis = event.path("date").asLong(0); + if (exceptionCode == null || epochMillis <= 0) continue; + String locationName = text(event, "location", "").trim(); + String identity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + '|' + epochMillis + '|' + + status + '|' + locationName.toUpperCase(Locale.ROOT); + values.add(new ExceptionObservation(sha256(identity), truncate(rawStatus, 128), exceptionCode, true, + queriedAt, blankToNull(locationCode(payload, locationName, "PICKUP_READY")), + blankToNull(locationName), sourceReference, rawHash)); + } + values.sort(Comparator.comparing(ExceptionObservation::observedAt)); + return List.copyOf(values); + } + + static String standardNode(String status) { + String value = normalizeStatus(status); + return switch (value) { + case "INGATE" -> "AT_ORIGIN_PORT"; + case "LOAD TO VESSEL" -> "LOADED_ON_VESSEL"; + case "VESSEL DEPARTURE" -> "DEPARTED_ORIGIN_PORT"; + case "IN TRANSIT" -> "IN_TRANSIT"; + case "VESSEL ARRIVAL", "ARRIVED AT DESTINATION" -> "ARRIVED_DESTINATION_PORT"; + case "DISCHARGE FROM VESSEL" -> "DISCHARGED_FROM_VESSEL"; + case "AVAILABLE FOR PICKUP" -> "PICKUP_READY"; + case "OUTGATE" -> "PICKED_UP"; + case "EMPTY RETURN", "EMPTY RETURNED" -> "EMPTY_RETURNED"; + default -> null; + }; + } + + static String exceptionCode(String status) { + return "CONTAINER ON HOLD - CUSTOMS CLEARANCE".equals(normalizeStatus(status)) ? "CUSTOMS_HOLD" : null; + } + + private static boolean matchesContainer(String requested, JsonNode payload) { + String actual = text(payload, "containerNumber", "") + text(payload, "checkDigit", ""); + return normalizeIdentifier(requested).equals(normalizeIdentifier(actual)); + } + + private static String locationCode(JsonNode payload, String location, String standardNode) { + String normalized = location.toUpperCase(Locale.ROOT); + String originName = text(payload, "originPort", "").toUpperCase(Locale.ROOT); + String loadPort = text(payload, "loadPort", "").toUpperCase(Locale.ROOT); + String destinationName = text(payload, "destPort", "").toUpperCase(Locale.ROOT); + String dischargePort = text(payload, "dischargePort", "").toUpperCase(Locale.ROOT); + if (!normalized.isBlank() && (normalized.equals(originName) || normalized.equals(loadPort))) { + return text(payload, "originCode", ""); + } + if (!normalized.isBlank() && (normalized.equals(destinationName) || normalized.equals(dischargePort))) { + return text(payload, "destCode", ""); + } + if ("ARRIVED_DESTINATION_PORT".equals(standardNode) + || "DISCHARGED_FROM_VESSEL".equals(standardNode) || "PICKUP_READY".equals(standardNode) + || "PICKED_UP".equals(standardNode) || "EMPTY_RETURNED".equals(standardNode)) { + return text(payload, "destCode", ""); + } + return ""; + } + + private static String normalizeStatus(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT); + } + + private static String normalizeIdentifier(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + + private static String text(JsonNode node, String field, String fallback) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? fallback : value.asText(fallback); + } + + private static LocalDateTime parseDate(JsonNode value) { + if (value == null || value.isNull()) return null; + if (value.isNumber()) { + long epoch = value.asLong(); + if (epoch <= 0) return null; + return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch < 10_000_000_000L ? epoch * 1000 : epoch), + ZoneOffset.UTC); + } + String text = value.asText("").trim(); + if (text.isEmpty()) return null; + try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm"))) { + try { return LocalDateTime.parse(text, formatter); } + catch (DateTimeParseException ignored) { } + } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE, + DateTimeFormatter.ofPattern("MM/dd/yy"), DateTimeFormatter.ofPattern("MM/dd/yyyy"))) { + try { return LocalDate.parse(text, formatter).atStartOfDay(); } + catch (DateTimeParseException ignored) { } + } + return null; + } + + private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; } + private static String truncate(String value, int max) { return value.length() <= max ? value : value.substring(0, max); } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_MATSON", client.configured(), client.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java new file mode 100644 index 000000000..470e86c68 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java @@ -0,0 +1,172 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class MatsonTrackingClient { + private static final Pattern TTR_PATTERN = Pattern.compile( + "id=[\\\"']ttr[\\\"'][^>]*value=[\\\"']([^\\\"']+)[\\\"']", Pattern.CASE_INSENSITIVE); + private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + + @Resource private IntegrationProperties properties; + @Resource private ObjectMapper objectMapper; + private IntegrationProperties.CarrierSchedule.Matson config; + private final Object requestLock = new Object(); + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().matson(); + } + + public boolean configured() { + return config != null && config.configured(); + } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) { + throw new MatsonTrackingException("MATSON_NOT_CONFIGURED", false, + "美森公开查询能力未启用或地址未配置"); + } + synchronized (requestLock) { + waitForRequestSlot(); + try { + return execute(request); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private TrackingResponse execute(QueryRequest request) { + CookieManager cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL); + HttpClient client = HttpClient.newBuilder() + .cookieHandler(cookies) + .connectTimeout(config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + try { + HttpResponse page = client.send(HttpRequest.newBuilder(config.pageUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.9") + .GET().build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureHttp(page.statusCode(), "MATSON_PAGE"); + Matcher matcher = TTR_PATTERN.matcher(page.body()); + if (!matcher.find() || matcher.group(1).isBlank()) { + throw new MatsonTrackingException("MATSON_TTR_MISSING", true, + "美森查询页未返回动态校验参数"); + } + + String form = "container_trackingnumber=" + encode(request.trackingNo().trim()) + + "&trackingVal=" + trackingValue(request.trackingType()) + + "&ttr=" + encode(matcher.group(1)); + HttpResponse response = client.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Origin", origin(config.pageUrl())) + .header("Referer", config.pageUrl().toString()) + .header("X-Requested-With", "XMLHttpRequest") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureHttp(response.statusCode(), "MATSON_QUERY"); + JsonNode payload = objectMapper.readTree(response.body()); + return new TrackingResponse(payload, response.body()); + } catch (MatsonTrackingException exception) { + throw exception; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new MatsonTrackingException("MATSON_INTERRUPTED", true, "美森查询被中断", exception); + } catch (IOException exception) { + throw new MatsonTrackingException("MATSON_IO_FAILED", true, "美森查询网络或响应解析失败", exception); + } + } + + private void waitForRequestSlot() { + long waitMillis = nextRequestAtMillis - System.currentTimeMillis(); + if (waitMillis <= 0) return; + try { + Thread.sleep(waitMillis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new MatsonTrackingException("MATSON_INTERRUPTED", true, "等待美森查询限流窗口时被中断", exception); + } + } + + private static void ensureHttp(int status, String stage) { + if (status >= 200 && status < 300) return; + if (status == 429) { + throw new MatsonTrackingException("MATSON_RATE_LIMITED", true, "美森查询触发访问频率限制"); + } + if (status == 403) { + throw new MatsonTrackingException("MATSON_SESSION_REJECTED", true, + "美森拒绝当前网页会话,请稍后重新获取动态参数"); + } + throw new MatsonTrackingException(stage + "_HTTP_" + status, status >= 500, + "美森查询返回异常 HTTP 状态:" + status); + } + + private static String trackingValue(String trackingType) { + return switch (trackingType.trim().toUpperCase(Locale.ROOT)) { + case "CONTAINER_NO" -> "tckconta"; + case "BOOKING_NO" -> "tckbok"; + case "BILL_OF_LADING" -> "tckbil"; + default -> throw new MatsonTrackingException("MATSON_TRACKING_TYPE_UNSUPPORTED", false, + "美森不支持该跟踪类型:" + trackingType); + }; + } + + private static String origin(URI uri) { + int port = uri.getPort(); + return uri.getScheme() + "://" + uri.getHost() + (port < 0 ? "" : ":" + port); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + public record TrackingResponse(JsonNode payload, String rawBody) { } + + public static final class MatsonTrackingException extends RuntimeException { + private final String code; + private final boolean retryable; + + public MatsonTrackingException(String code, boolean retryable, String message) { + super(message); this.code = code; this.retryable = retryable; + } + + public MatsonTrackingException(String code, boolean retryable, String message, Throwable cause) { + super(message, cause); this.code = code; this.retryable = retryable; + } + + public String code() { return code; } + public boolean retryable() { return retryable; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..72af144ee --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapter.java @@ -0,0 +1,154 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.SmlTrackingClient.SmlTrackingException; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Component +@Order(5) +public class SmlCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "SML_PUBLIC_TRACKING"; + private static final Map NODE_MAP = Map.of( + "FOTMAD", "AT_ORIGIN_PORT", + "FLVMLO", "LOADED_ON_VESSEL", + "FLVMDO", "DEPARTED_ORIGIN_PORT", + "FUVMAD", "ARRIVED_DESTINATION_PORT", + "FUVMBD", "ARRIVED_DESTINATION_PORT", + "FUVMUD", "DISCHARGED_FROM_VESSEL", + "MITYAD", "EMPTY_RETURNED"); + private final SmlTrackingClient client; + + public SmlCarrierScheduleProviderAdapter(SmlTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "SML".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + try { + SmlTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response, queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, + List.of(), null, null, queriedAt); + } catch (SmlTrackingException exception) { + return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE", + List.of(), List.of(), exception.code(), exception.getMessage(), queriedAt); + } + } + + List observations(String containerNo, SmlTrackingClient.TrackingResponse response) { + return observations(containerNo, response, LocalDateTime.now(ZoneOffset.UTC)); + } + + private List observations(String containerNo, SmlTrackingClient.TrackingResponse response, + LocalDateTime queriedAt) { + if (response == null || response.timeline() == null) return List.of(); + String rawHash = sha256(response.rawBody() == null ? response.timeline().toString() : response.rawBody()); + String reference = "SML:" + response.bookingNo() + ':' + response.copNo(); + Map facts = new LinkedHashMap<>(); + collect(response.timeline(), containerNo, reference, rawHash, queriedAt, facts); + return facts.values().stream().sorted(Comparator.comparing(Observation::observedAt)).toList(); + } + + private static void collect(JsonNode node, String containerNo, String reference, String rawHash, + LocalDateTime queriedAt, + Map facts) { + if (node == null) return; + if (node.isObject()) { + String providerNode = directText(node, "actStsMapgCd", "actCd", "actStsCd", "eventCode"); + String actType = directText(node, "actTpCd", "actualType"); + String standardNode = providerNode == null ? null : NODE_MAP.get(providerNode.toUpperCase(Locale.ROOT)); + LocalDateTime eventAt = parseDate(directText(node, "eventDt", "actDt", "skdDt", "eventDate", "date")); + if (standardNode != null && eventAt != null && ("A".equalsIgnoreCase(actType) || "E".equalsIgnoreCase(actType))) { + String locationCode = directText(node, "locCd", "locationCode", "portCd"); + String locationName = directText(node, "locNm", "locationName", "portNm"); + String identity = normalize(containerNo) + '|' + providerNode.toUpperCase(Locale.ROOT) + '|' + + actType.toUpperCase(Locale.ROOT) + '|' + eventAt + '|' + normalize(locationCode) + '|' + + normalize(locationName); + String eventId = sha256(identity); + boolean actual = "A".equalsIgnoreCase(actType); + facts.putIfAbsent(eventId, new Observation(eventId, providerNode.toUpperCase(Locale.ROOT), + standardNode, queriedAt, + actual ? null : eventAt, actual ? eventAt : null, blank(locationCode), blank(locationName), + reference, rawHash)); + } + Iterator children = node.elements(); + while (children.hasNext()) collect(children.next(), containerNo, reference, rawHash, queriedAt, facts); + } else if (node.isArray()) for (JsonNode child : node) { + collect(child, containerNo, reference, rawHash, queriedAt, facts); + } + } + + static String standardNode(String providerCode) { + return providerCode == null ? null : NODE_MAP.get(providerCode.trim().toUpperCase(Locale.ROOT)); + } + + private static String directText(JsonNode node, String... names) { + for (String name : names) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + var field = fields.next(); + if (field.getKey().equalsIgnoreCase(name) && field.getValue().isValueNode()) { + String value = field.getValue().asText("").trim(); + if (!value.isEmpty()) return value; + } + } + } + return null; + } + + private static LocalDateTime parseDate(String value) { + if (value == null || value.isBlank()) return null; + String text = value.trim(); + try { + long epoch = Long.parseLong(text); + return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch < 10_000_000_000L ? epoch * 1000 : epoch), + ZoneOffset.UTC); + } catch (NumberFormatException ignored) { } + try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), DateTimeFormatter.ofPattern("yyyyMMddHHmm"))) { + try { return LocalDateTime.parse(text, formatter); } + catch (DateTimeParseException ignored) { } + } + return null; + } + + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + @Override + public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE_SML", client.configured(), client.configured()); } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java new file mode 100644 index 000000000..575b9a68c --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java @@ -0,0 +1,165 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.net.http.HttpTimeoutException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; + +@Component +public class SmlTrackingClient { + @Resource private IntegrationProperties properties; + @Resource private ObjectMapper objectMapper; + private IntegrationProperties.CarrierSchedule.Sml config; + private HttpClient httpClient; + private final Object requestLock = new Object(); + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().sml(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? java.time.Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL).build(); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) throw new SmlTrackingException("SML_NOT_CONFIGURED", false, "SML 查询能力未启用"); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + throw new SmlTrackingException("SML_CONTAINER_REQUIRED", false, "SML 自动节点只按柜号查询"); + } + synchronized (requestLock) { + waitForSlot(); + try { + String container = request.trackingNo().trim(); + JsonResponse lookup = call("f_cmd=122&cntr_no=" + encode(container) + "&cust_cd=", "LOOKUP"); + validateBusinessResult(lookup.payload()); + String bookingNo = findText(lookup.payload(), "bkgNo", "bkg_no"); + String copNo = findText(lookup.payload(), "copNo", "cop_no"); + if (bookingNo == null || copNo == null) { + throw new SmlTrackingException("SML_BUSINESS_KEYS_MISSING", false, + "SML 查询结果缺少 bkgNo 或 copNo"); + } + JsonResponse timeline = call("f_cmd=125&cntr_no=" + encode(container) + "&bkg_no=" + + encode(bookingNo) + "&cop_no=" + encode(copNo), "TIMELINE"); + validateBusinessResult(timeline.payload()); + JsonResponse schedule = call("f_cmd=124&bkg_no=" + encode(bookingNo), "SCHEDULE"); + JsonResponse clearance = call("f_cmd=126&cntr_no=" + encode(container) + "&bkg_no=" + + encode(bookingNo), "CLEARANCE"); + return new TrackingResponse(bookingNo, copNo, timeline.payload(), schedule.payload(), + clearance.payload(), lookup.raw() + timeline.raw() + schedule.raw() + clearance.raw()); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private JsonResponse call(String form, String stage) { + try { + HttpResponse response = httpClient.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() == 429) throw new SmlTrackingException("SML_RATE_LIMITED", true, "SML 访问限流"); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new SmlTrackingException("SML_" + stage + "_HTTP_" + response.statusCode(), + response.statusCode() >= 500, "SML 查询返回异常 HTTP 状态:" + response.statusCode()); + } + try { return new JsonResponse(objectMapper.readTree(response.body()), response.body()); } + catch (Exception exception) { + throw new SmlTrackingException("SML_" + stage + "_NON_JSON", true, + "SML 查询返回 HTML 或无效 JSON", exception); + } + } catch (SmlTrackingException exception) { + throw exception; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new SmlTrackingException("SML_INTERRUPTED", true, "SML 查询被中断", exception); + } catch (HttpTimeoutException exception) { + throw new SmlTrackingException("SML_" + stage + "_TIMEOUT", true, "SML 查询超时", exception); + } catch (IOException exception) { + throw new SmlTrackingException("SML_IO_FAILED", true, "SML 查询网络失败", exception); + } + } + + private static void validateBusinessResult(JsonNode payload) { + if (payload == null || payload.isNull()) throw new SmlTrackingException("SML_EMPTY_RESULT", false, "SML 返回空结果"); + String result = findText(payload, "TRANS_RESULT_KEY", "transResultKey"); + if (result != null && !"S".equalsIgnoreCase(result)) { + throw new SmlTrackingException("SML_BUSINESS_FAILED", false, "SML 业务查询失败:" + result); + } + Long count = findLong(payload, "count", "totalCount"); + if (count != null && count == 0L) throw new SmlTrackingException("SML_NOT_FOUND", false, "SML 未找到业务对象"); + } + + static String findText(JsonNode node, String... names) { + if (node == null) return null; + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + var field = fields.next(); + for (String name : names) if (field.getKey().equalsIgnoreCase(name) + && field.getValue().isValueNode() && !field.getValue().asText().isBlank()) { + return field.getValue().asText().trim(); + } + String nested = findText(field.getValue(), names); + if (nested != null) return nested; + } + } else if (node.isArray()) for (JsonNode child : node) { + String nested = findText(child, names); if (nested != null) return nested; + } + return null; + } + + private static Long findLong(JsonNode node, String... names) { + String value = findText(node, names); + if (value == null) return null; + try { return Long.parseLong(value); } + catch (NumberFormatException ignored) { return null; } + } + + private void waitForSlot() { + long wait = nextRequestAtMillis - System.currentTimeMillis(); + if (wait <= 0) return; + try { Thread.sleep(wait); } + catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new SmlTrackingException("SML_INTERRUPTED", true, "等待 SML 限流窗口时被中断", exception); + } + } + + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } + + private record JsonResponse(JsonNode payload, String raw) { } + public record TrackingResponse(String bookingNo, String copNo, JsonNode timeline, JsonNode schedule, + JsonNode clearance, String rawBody) { } + + public static final class SmlTrackingException extends RuntimeException { + private final String code; private final boolean retryable; + public SmlTrackingException(String code, boolean retryable, String message) { + super(message); this.code = code; this.retryable = retryable; + } + public SmlTrackingException(String code, boolean retryable, String message, Throwable cause) { + super(message, cause); this.code = code; this.retryable = retryable; + } + public String code() { return code; } + public boolean retryable() { return retryable; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..3d7e531ef --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapter.java @@ -0,0 +1,199 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.ZimTrackingClient.ContainerEvent; +import com.baiyue.integration.provider.ZimTrackingClient.RouteDetail; +import com.baiyue.integration.provider.ZimTrackingClient.ZimTrackingException; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Component +@Order(7) +public class ZimCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "ZIM_OFFICIAL_CARGO_TRACKING"; + private static final Map EVENT_MAP = Map.ofEntries( + Map.entry("EXPORT GATE-IN FROM CUSTOMER TO PORT OF LOADING", "AT_ORIGIN_PORT"), + Map.entry("EXPORT GATE-IN TO PORT OF LOADING", "AT_ORIGIN_PORT"), + Map.entry("CONTAINER WAS LOADED AT TRANSSHIPMENT PORT TO TRANSSHIPMENT PORT", "LOADED_ON_VESSEL"), + Map.entry("VESSEL DEPARTURE FROM FIRST TRANSSHIPMENT PORT TO NEXT TRANSSHIPMENT PORT", "IN_TRANSIT"), + Map.entry("CONTAINER LOADED AT PORT OF LOADING", "LOADED_ON_VESSEL"), + Map.entry("CONTAINER WAS LOADED AT PORT OF LOADING", "LOADED_ON_VESSEL"), + Map.entry("CONTAINER WAS LOADED AT PORT OF LOADING TO PORT OF DISCHARGE", "LOADED_ON_VESSEL"), + Map.entry("VESSEL DEPARTURE FROM PORT OF LOADING", "DEPARTED_ORIGIN_PORT"), + Map.entry("VESSEL DEPARTURE FROM PORT OF LOADING TO PORT OF DISCHARGE", "DEPARTED_ORIGIN_PORT")); + private final ZimTrackingClient client; + + public ZimCarrierScheduleProviderAdapter(ZimTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "ZIM".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + try { + ZimTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response, queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, + List.of(), null, null, queriedAt); + } catch (ZimTrackingException exception) { + return new QueryResult(PROVIDER, exception.outcome(), List.of(), List.of(), exception.code(), + exception.getMessage(), queriedAt); + } + } + + List observations(String containerNo, ZimTrackingClient.TrackingResponse response, + LocalDateTime queriedAt) { + if (response == null || response.payload() == null || response.payload().bkBlDetails() == null) return List.of(); + String normalizedContainer = normalize(containerNo); + ZimTrackingClient.ContainerDetail unit = response.payload().bkBlDetails().safeContainers().stream() + .filter(value -> normalizedContainer.equals(normalize(value.containerNumber()))) + .findFirst().orElse(null); + if (unit == null) return List.of(); + String rawHash = sha256(response.rawBody() == null ? normalizedContainer : response.rawBody()); + String reference = sourceReference(response.payload(), normalizedContainer); + Map facts = new LinkedHashMap<>(); + for (ContainerEvent event : unit.safeEvents()) { + String eventName = normalizeEvent(event.eventName()); + String standardNode = EVENT_MAP.get(eventName); + LocalDateTime actualAt = parseDate(first(event.activityDateTz(), event.activityDate())); + if (standardNode == null || actualAt == null) continue; + add(facts, normalizedContainer, truncate(eventName, 64), standardNode, queriedAt, null, actualAt, + blank(event.portCode()), locationName(event.port(), event.country()), reference, rawHash, + event.vesselName(), event.voyage(), event.leg()); + } + List routes = response.payload().safeRoutes(); + RouteDetail firstSailing = routes.stream() + .filter(value -> parseDate(first(value.sailingDateTz(), value.sailingDateDT())) != null) + .findFirst().orElse(null); + if (firstSailing != null) { + addEstimated(facts, normalizedContainer, "ROUTE_POL_SAILING_PLAN", "DEPARTED_ORIGIN_PORT", + parseDate(first(firstSailing.sailingDateTz(), firstSailing.sailingDateDT())), queriedAt, + firstSailing, reference, rawHash); + } + RouteDetail lastArrival = null; + for (int index = routes.size() - 1; index >= 0; index--) { + RouteDetail candidate = routes.get(index); + if (parseDate(first(candidate.arrivalDateTz(), candidate.arrivalDateDT())) != null) { + lastArrival = candidate; break; + } + } + if (lastArrival != null) { + addEstimated(facts, normalizedContainer, "ROUTE_POD_ARRIVAL_PLAN", "ARRIVED_DESTINATION_PORT", + parseDate(first(lastArrival.arrivalDateTz(), lastArrival.arrivalDateDT())), queriedAt, + lastArrival, reference, rawHash); + } + if (response.payload().finalETA() != null) { + addEstimated(facts, normalizedContainer, "FINAL_ETA", "ARRIVED_DESTINATION_PORT", + parseDate(first(response.payload().finalETA().etaValue(), response.payload().finalETA().etaValueDate())), + queriedAt, null, reference, rawHash); + } + return facts.values().stream().sorted(Comparator.comparing(Observation::standardNode) + .thenComparing(value -> value.actualAt() == null ? value.estimatedAt() : value.actualAt())).toList(); + } + + static String standardNode(String eventName) { + return EVENT_MAP.get(normalizeEvent(eventName)); + } + + private static void addEstimated(Map facts, String container, String providerNode, + String standardNode, LocalDateTime estimatedAt, LocalDateTime queriedAt, + RouteDetail route, String reference, String rawHash) { + if (estimatedAt == null) return; + add(facts, container, providerNode, standardNode, queriedAt, estimatedAt, null, + route == null ? null : blank(route.portCode()), + route == null ? null : locationName(route.port(), route.country()), reference, rawHash, + route == null ? null : route.vesselName(), route == null ? null : route.voyage(), + route == null ? null : route.leg()); + } + + private static void add(Map facts, String container, String providerNode, + String standardNode, LocalDateTime queriedAt, LocalDateTime estimatedAt, + LocalDateTime actualAt, String locationCode, String locationName, + String reference, String rawHash, String vesselName, String voyage, String leg) { + LocalDateTime eventAt = actualAt == null ? estimatedAt : actualAt; + String eventId = sha256(container + '|' + providerNode + '|' + eventAt + '|' + + normalize(locationCode) + '|' + normalize(locationName) + '|' + clean(vesselName) + '|' + + clean(voyage) + '|' + clean(leg)); + facts.putIfAbsent(eventId, new Observation(eventId, providerNode, standardNode, queriedAt, + estimatedAt, actualAt, locationCode, locationName, reference, rawHash)); + } + + private static String first(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) return value.trim(); + } + return ""; + } + + private static String sourceReference(ZimTrackingClient.TracingPayload payload, String container) { + StringBuilder value = new StringBuilder("ZIM:CONTAINER:").append(container); + if (!clean(payload.bookingReference()).isEmpty()) value.append("|BOOKING:").append(clean(payload.bookingReference())); + if (!clean(payload.blReference()).isEmpty()) value.append("|BL:").append(clean(payload.blReference())); + return truncate(value.toString(), 500); + } + + private static String locationName(String port, String country) { + String name = clean(port); String nation = clean(country); + return blank(name + (name.isEmpty() || nation.isEmpty() ? "" : ", ") + nation); + } + + private static String normalizeEvent(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT); + } + + private static LocalDateTime parseDate(String value) { + if (value == null || value.isBlank()) return null; + String text = value.trim(); + try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + try { return LocalDateTime.ofInstant(OffsetDateTime.parse(text).toInstant(), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))) { + try { return LocalDateTime.parse(text, formatter); } + catch (DateTimeParseException ignored) { } + } + try { return LocalDate.parse(text, DateTimeFormatter.ISO_LOCAL_DATE).atStartOfDay(); } + catch (DateTimeParseException ignored) { } + return null; + } + + private static String clean(String value) { return value == null ? "" : value.trim(); } + private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String truncate(String value, int max) { return value.length() <= max ? value : value.substring(0, max); } + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_ZIM", client.configured(), client.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java new file mode 100644 index 000000000..f56a6b0dd --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java @@ -0,0 +1,207 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Semaphore; + +@Component +public class ZimTrackingClient { + private static final Duration TOKEN_REFRESH_SKEW = Duration.ofMinutes(1); + @Resource private IntegrationProperties properties; + @Resource private ObjectMapper objectMapper; + private IntegrationProperties.CarrierSchedule.Zim config; + private HttpClient httpClient; + private Semaphore concurrency; + private final Object tokenLock = new Object(); + private volatile AccessToken cachedToken; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().zim(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL).build(); + concurrency = new Semaphore(config == null ? 1 : config.effectiveMaxConcurrency()); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) manual("ZIM_OFFICIAL_API_NOT_CONFIGURED", "ZIM Tracing API 未启用或缺少授权配置"); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + manual("ZIM_CONTAINER_REQUIRED", "ZIM 首批已验证契约仅支持柜号查询"); + } + boolean acquired = false; + try { + concurrency.acquire(); acquired = true; + return execute(normalize(request.trackingNo()), false); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw retryable("ZIM_INTERRUPTED", "ZIM Tracing API 查询被中断", exception); + } finally { + if (acquired) concurrency.release(); + } + } + + private TrackingResponse execute(String container, boolean refreshedAfterUnauthorized) { + String token = accessToken(false); + HttpResponse response = send(HttpRequest.newBuilder(tracingUri(container)) + .timeout(config.effectiveRequestTimeout()).header("Accept", "application/json") + .header("Ocp-Apim-Subscription-Key", config.subscriptionKey()) + .header("Authorization", "bearer " + token).GET().build(), "TRACING"); + if (response.statusCode() == 401 && !refreshedAfterUnauthorized) { + invalidateToken(token); return execute(container, true); + } + if (response.statusCode() == 401 || response.statusCode() == 403) { + manual("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 授权或来源环境校验失败"); + } + if (response.statusCode() == 429 || response.statusCode() >= 500) { + throw retryable("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 暂时不可用", null); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + manual("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 拒绝查询"); + } + TracingPayload payload = tracingPayload(response.body()); + validateBusinessResult(payload, container); + return new TrackingResponse(payload, response.body()); + } + + private String accessToken(boolean forceRefresh) { + AccessToken current = cachedToken; + if (!forceRefresh && current != null && current.usableAt(Instant.now())) return current.value(); + synchronized (tokenLock) { + current = cachedToken; + if (!forceRefresh && current != null && current.usableAt(Instant.now())) return current.value(); + String form = "grant_type=client_credentials&client_id=" + encode(config.clientId()) + + "&client_secret=" + encode(config.clientSecret()) + "&scope=tracing"; + HttpResponse response = send(HttpRequest.newBuilder(config.authorizationUrl()) + .timeout(config.effectiveRequestTimeout()).header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), "AUTH"); + if (response.statusCode() == 429 || response.statusCode() >= 500) { + throw retryable("ZIM_AUTH_HTTP_" + response.statusCode(), "ZIM OAuth 服务暂时不可用", null); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + manual("ZIM_AUTH_HTTP_" + response.statusCode(), "ZIM OAuth 客户端授权失败"); + } + JsonNode payload = json(response.body(), "ZIM_AUTH_NON_JSON"); + String value = payload.path("access_token").asText("").trim(); + if (value.isEmpty()) manual("ZIM_AUTH_TOKEN_MISSING", "ZIM OAuth 响应缺少 access_token"); + long expiresIn = Math.max(60L, payload.path("expires_in").asLong(3600L)); + cachedToken = new AccessToken(value, Instant.now().plusSeconds(expiresIn)); + return value; + } + } + + private HttpResponse send(HttpRequest request, String stage) { + try { return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); } + catch (HttpTimeoutException exception) { + throw retryable("ZIM_" + stage + "_TIMEOUT", "ZIM 官方 API 请求超时", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw retryable("ZIM_" + stage + "_INTERRUPTED", "ZIM 官方 API 请求被中断", exception); + } catch (IOException exception) { + throw retryable("ZIM_" + stage + "_IO_FAILED", "ZIM 官方 API 网络失败", exception); + } + } + + private JsonNode json(String body, String code) { + try { return objectMapper.readTree(body); } + catch (Exception exception) { throw retryable(code, "ZIM 官方 API 返回无效 JSON", exception); } + } + + private TracingPayload tracingPayload(String body) { + try { return objectMapper.readValue(body, TracingPayload.class); } + catch (Exception exception) { throw retryable("ZIM_TRACING_NON_JSON", "ZIM Tracing V2 返回无效 JSON", exception); } + } + + private void validateBusinessResult(TracingPayload payload, String requestedContainer) { + if (payload == null || payload.bkBlDetails() == null) manual("ZIM_EMPTY_RESULT", "ZIM 返回空业务对象"); + List containers = payload.bkBlDetails().safeContainers(); + boolean matched = containers.stream().anyMatch(value -> requestedContainer.equals(normalize(value.containerNumber()))); + if (containers.isEmpty() || !matched) manual("ZIM_NOT_FOUND", "ZIM 未返回匹配柜号的业务对象"); + } + + private URI tracingUri(String container) { + return URI.create(config.tracingBaseUrl().toString().replaceAll("/+$", "") + '/' + encode(container)); + } + private void invalidateToken(String usedToken) { + synchronized (tokenLock) { + if (cachedToken != null && cachedToken.value().equals(usedToken)) cachedToken = null; + } + } + + private static void manual(String code, String message) { + throw new ZimTrackingException("MANUAL_REQUIRED", code, message); + } + private static ZimTrackingException retryable(String code, String message, Throwable cause) { + return new ZimTrackingException("RETRYABLE_FAILURE", code, message, cause); + } + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } + + private record AccessToken(String value, Instant expiresAt) { + boolean usableAt(Instant now) { return expiresAt.minus(TOKEN_REFRESH_SKEW).isAfter(now); } + } + public record TrackingResponse(TracingPayload payload, String rawBody) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record TracingPayload(FinalEta finalETA, List routeDetails, BkBlDetails bkBlDetails, + String bookingReference, String blReference) { + public List safeRoutes() { return routeDetails == null ? List.of() : List.copyOf(routeDetails); } + } + @JsonIgnoreProperties(ignoreUnknown = true) + public record FinalEta(String etaValue, String etaValueDate, String podInd) { } + @JsonIgnoreProperties(ignoreUnknown = true) + public record RouteDetail(String sailingDateTz, String sailingDateDT, String arrivalDateTz, + String arrivalDateDT, String portCode, String port, String country, + String vesselName, String voyage, String leg) { } + @JsonIgnoreProperties(ignoreUnknown = true) + public record BkBlDetails(List consContainerList) { + public List safeContainers() { + return consContainerList == null ? List.of() : List.copyOf(consContainerList); + } + } + @JsonIgnoreProperties(ignoreUnknown = true) + public record ContainerDetail(String containerNumber, List containerEventsList) { + public List safeEvents() { + return containerEventsList == null ? List.of() : List.copyOf(containerEventsList); + } + } + @JsonIgnoreProperties(ignoreUnknown = true) + public record ContainerEvent(String activityDateTz, String activityDate, String eventName, + String portCode, String port, String country, + String vesselName, String voyage, String leg) { } + public static final class ZimTrackingException extends RuntimeException { + private final String outcome; private final String code; + public ZimTrackingException(String outcome, String code, String message) { + super(message); this.outcome = outcome; this.code = code; + } + public ZimTrackingException(String outcome, String code, String message, Throwable cause) { + super(message, cause); this.outcome = outcome; this.code = code; + } + public String outcome() { return outcome; } + public String code() { return code; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java new file mode 100644 index 000000000..316bbf195 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java @@ -0,0 +1,49 @@ +package com.baiyue.integration.service; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; +import com.baiyue.integration.domain.CarrierScheduleStandardNode; +import com.baiyue.integration.domain.CarrierScheduleExceptionCode; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +@Service +public class CarrierScheduleProviderService { + private static final Set OUTCOMES = Set.of("SUCCESS", "NO_CHANGE", "RETRYABLE_FAILURE", + "PERMANENT_FAILURE", "MANUAL_REQUIRED"); + private final List adapters; + + public CarrierScheduleProviderService(List adapters) { + this.adapters = List.copyOf(adapters); + } + + public QueryResult query(QueryRequest request) { + QueryResult result = adapters.stream().filter(adapter -> adapter.supports(request.carrierCode())).findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "没有支持该船司的轨迹供应商适配器:" + request.carrierCode())) + .query(request); + if (result == null || result.observations() == null || result.exceptions() == null + || !OUTCOMES.contains(result.outcome())) { + throw new IllegalStateException("轨迹供应商返回空结果"); + } + if (result.observations().stream().anyMatch(value -> value == null + || !CarrierScheduleStandardNode.supports(value.standardNode()) + || value.providerEventId() == null || value.providerEventId().isBlank() + || value.observedAt() == null || value.rawPayloadHash() == null + || !value.rawPayloadHash().matches("[0-9a-f]{64}"))) { + throw new IllegalStateException("轨迹供应商返回了无效标准节点"); + } + if (result.exceptions().stream().anyMatch(value -> value == null + || !CarrierScheduleExceptionCode.supports(value.exceptionCode()) + || value.providerEventId() == null || value.providerEventId().isBlank() + || value.providerStatus() == null || value.providerStatus().isBlank() + || value.observedAt() == null || value.rawPayloadHash() == null + || !value.rawPayloadHash().matches("[0-9a-f]{64}"))) { + throw new IllegalStateException("轨迹供应商返回了无效异常事实"); + } + return result; + } +} diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index e5508b64f..cca1f3168 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -58,6 +58,42 @@ baiyue: aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:} aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:} region: ${AMAZON_SP_API_REGION:} + 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} management: endpoints: diff --git a/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml new file mode 100644 index 000000000..b376f8cea --- /dev/null +++ b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml @@ -0,0 +1,71 @@ +openapi: 3.0.3 +info: + title: Carrier Schedule Provider Internal API + version: 1.0.0 + description: Internal provider adapter contract. It owns normalization, timeout, rate-limit and provider errors, not business tasks. +paths: + /internal/integrations/carrier-schedules/query: + post: + summary: Query one carrier tracking identifier through the configured provider adapter + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QueryRequest'} + responses: + '200': {description: Standard observations or an explicit normalized failure/manual outcome} +components: + schemas: + QueryRequest: + type: object + required: [carrierCode, trackingType, trackingNo] + properties: + carrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} + trackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} + trackingNo: {type: string, minLength: 1, maxLength: 128} + StandardNode: + type: string + enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT, DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED] + ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]} + QueryResult: + type: object + required: [providerCode, outcome, observations, exceptions] + properties: + providerCode: {type: string} + outcome: {type: string, enum: [SUCCESS, NO_CHANGE, RETRYABLE_FAILURE, PERMANENT_FAILURE, MANUAL_REQUIRED]} + observations: + type: array + items: + type: object + required: [providerEventId, providerNodeCode, standardNode, observedAt, rawPayloadHash] + properties: + providerEventId: {type: string} + providerNodeCode: {type: string} + standardNode: {$ref: '#/components/schemas/StandardNode'} + observedAt: {type: string, format: date-time} + estimatedAt: {type: string, format: date-time, nullable: true} + actualAt: {type: string, format: date-time, nullable: true} + locationCode: {type: string, nullable: true} + locationName: {type: string, nullable: true} + sourceReference: {type: string, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} + exceptions: + type: array + items: + type: object + required: [providerEventId, providerStatus, exceptionCode, active, observedAt, rawPayloadHash] + properties: + providerEventId: {type: string, maxLength: 128} + providerStatus: {type: string, maxLength: 128} + exceptionCode: {$ref: '#/components/schemas/ExceptionCode'} + active: {type: boolean} + observedAt: {type: string, format: date-time} + locationCode: {type: string, nullable: true} + locationName: {type: string, nullable: true} + sourceReference: {type: string, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..2a09fb252 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,74 @@ +package com.baiyue.integration.provider; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class EvergreenCarrierScheduleProviderAdapterTest { + private static final String CONTAINER_HTML = """ +
other
+ + + + + + + + +
Method地点箱号货柜动态日期柜型VGM船名航次
CYNINGBO, CHINA (CN) EGSU9319340 Loaded (FCL) on vesselAUG-07-202640HQEVER LOVELY 1232-067E
CYNINGBO, CHINA (CN)EITU1262610Loaded (FCL) on vesselAUG-06-202640HQEVER LOVELY 1232-067E
+ """; + private static final String BILL_HTML = """ +
预计装船日AUG-07-2026
+ """; + + @Test + void mapsTheExactContainerRowAndPreservesDayPrecisionWithoutUsingObservedAtAsActual() { + var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, BILL_HTML, "0000", + CONTAINER_HTML + BILL_HTML); + LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 15, 30); + + var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class)) + .observations("egsu9319340", response, queriedAt); + + assertThat(values).hasSize(2); + assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt); + assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement().satisfies(value -> { + assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL"); + assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0)); + assertThat(value.actualAt()).isNotEqualTo(queriedAt); + assertThat(value.locationCode()).isEqualTo("CN"); + assertThat(value.locationName()).isEqualTo("NINGBO, CHINA (CN)"); + assertThat(value.sourceReference()).contains("DATE:AUG-07-2026", "PRECISION:DAY", "ZONE:Asia/Shanghai") + .doesNotContain("000000000000"); + }); + assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement() + .satisfies(value -> assertThat(value.estimatedAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0))); + } + + @Test + void secondConfirmedFixtureRowUsesItsOwnDateAndUnknownStatusesStayUnmapped() { + var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, "
", + "0000", CONTAINER_HTML); + var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class)) + .observations("EITU1262610", response, LocalDateTime.of(2026, 8, 21, 15, 30)); + + assertThat(values).singleElement().satisfies(value -> { + assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 6, 0, 0)); + assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL"); + }); + assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Loaded (FCL) on vessel")) + .isEqualTo("LOADED_ON_VESSEL"); + assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Discharged from vessel")).isNull(); + } + + @Test + void missingStableHeaderOrExactContainerRowIsNotParsed() { + assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow( + "
箱号日期
EGSU9319340AUG-07-2026
", + "EGSU9319340")).isNull(); + assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow(CONTAINER_HTML, "OTHER0000000")).isNull(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenTrackingClientTest.java new file mode 100644 index 000000000..d84b1d269 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenTrackingClientTest.java @@ -0,0 +1,91 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EvergreenTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void executesContainerThenRelatedBillPostsWithoutExposingTheFullBill() throws Exception { + List forms = new ArrayList<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> { + String form = body(exchange); forms.add(form); + String html = form.startsWith("TYPE=CNTR") + ? "
" + : "
预计装船日AUG-07-2026
"; + respond(exchange, 200, html); + }); + server.start(); + + var result = client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340")); + + assertThat(forms).containsExactly("TYPE=CNTR&CNTR=EGSU9319340", "TYPE=BL&BL=000000000000"); + assertThat(result.billSuffix()).isEqualTo("0000"); + assertThat(result.toString()).doesNotContain("000000000000"); + } + + @Test + void extractsTheRelatedBillFromHiddenInputOrDispInfoLink() { + assertThat(EvergreenTrackingClient.relatedBill( + "" )).isEqualTo("000000000000"); + assertThat(EvergreenTrackingClient.relatedBill( + "detail" )).isEqualTo("000000000000"); + } + + @Test + void nonHtmlAndMissingRelatedBillBecomeTraceableManualFailures() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, "{\"unexpected\":true}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340"))) + .isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class, + value -> assertThat(value.code()).isEqualTo("EMC_CONTAINER_NON_HTML")); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, "
")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340"))) + .isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class, + value -> assertThat(value.code()).isEqualTo("EMC_RELATED_BL_MISSING")); + } + + private EvergreenTrackingClient client() { + var evergreen = new IntegrationProperties.CarrierSchedule.Evergreen(true, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"), + Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO); + EvergreenTrackingClient client = new EvergreenTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, null, null, null, evergreen))); + client.initialize(); return client; + } + private static String body(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..f5cdfd456 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,95 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.provider.HedeTrackingClient.TrackingResponse; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class HedeCarrierScheduleProviderAdapterTest { + @Test + void parsesConfirmedMainTableColumnsAndDoesNotTurnEtaIntoAnActualNode() throws Exception { + String html = row("XYLU8251457", "2026-08-20 00:00", "2026-09-09 00:00", + "", "", "", "", "", ""); + + var rows = HedeCarrierScheduleProviderAdapter.rows(html); + HedeTrackingClient client = mock(HedeTrackingClient.class); + HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client); + + assertThat(rows).singleElement().satisfies(value -> { + assertThat(value.containerNo()).isEqualTo("XYLU8251457"); + assertThat(value.vessel()).isEqualTo("POSEN"); + assertThat(value.voyage()).isEqualTo("2607W"); + assertThat(value.estimatedDeparture()).isEqualTo("2026-08-20 00:00"); + assertThat(value.estimatedArrival()).isEqualTo("2026-09-09 00:00"); + }); + assertThat(adapter.observations("XYLU8251457", html)).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT"); + assertThat(adapter.observations("XYLU8251457", html)).allSatisfy(value -> { + assertThat(value.estimatedAt()).isNotNull(); + assertThat(value.actualAt()).isNull(); + }); + } + + @Test + void mapsOnlyActualHedeMilestonesForTheRequestedContainer() { + String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00", + "2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "", ""); + HedeTrackingClient client = mock(HedeTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673"))) + .thenReturn(new TrackingResponse(html)); + HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client); + + var result = adapter.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673")); + + assertThat(result.outcome()).isEqualTo("SUCCESS"); + assertThat(result.observations()).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT", "AT_ORIGIN_PORT", + "LOADED_ON_VESSEL"); + assertThat(result.observations()).allSatisfy(value -> { + assertThat(value.providerEventId()).hasSize(64); + assertThat(value.rawPayloadHash()).hasSize(64); + assertThat(value.sourceReference()).contains("FFAU4879673").contains("POSEN/2607W"); + }); + assertThat(result.observations()).extracting(value -> value.observedAt()) + .containsOnly(result.observations().get(0).observedAt()); + } + + @Test + void actualEmptyReturnIsMappedPrecisely() throws Exception { + String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00", + "2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "", + "2026-08-28 08:00"); + HedeTrackingClient client = mock(HedeTrackingClient.class); + var result = new HedeCarrierScheduleProviderAdapter(client).observations("FFAU4879673", html); + assertThat(result).filteredOn(value -> "EMPTY_RETURNED".equals(value.standardNode())) + .singleElement().satisfies(value -> { + assertThat(value.actualAt()).isNotNull(); + assertThat(value.estimatedAt()).isNull(); + }); + } + + @Test + void refusesAmbiguousBillLevelAutomation() { + HedeTrackingClient client = mock(HedeTrackingClient.class); + HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client); + + var result = adapter.query(new QueryRequest("HEDE", "BILL_OF_LADING", "HDE123")); + + assertThat(result.outcome()).isEqualTo("PERMANENT_FAILURE"); + assertThat(result.errorCode()).isEqualTo("HEDE_CONTAINER_REQUIRED"); + } + + private static String row(String container, String etd, String eta, String emptyOut, String fullIn, + String loaded, String discharged, String fullOut, String emptyReturned) { + return "" + + td("***") + td(container) + td("POSEN") + td("2607W") + td(etd) + td(eta) + + td(emptyOut) + td(fullIn) + td(loaded) + td(discharged) + td(fullOut) + td(emptyReturned) + + td("查看") + "
"; + } + + private static String td(String value) { return "" + value + ""; } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/HedeTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/HedeTrackingClientTest.java new file mode 100644 index 000000000..b7b229a55 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/HedeTrackingClientTest.java @@ -0,0 +1,59 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class HedeTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void postsTheConfirmedHedeFormAsUtf8() throws Exception { + AtomicReference postedBody = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/getVBilldynamic", exchange -> { + postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + respond(exchange, 200, "" + + "
***XYLU8251457
"); + }); + server.start(); + + var hede = new IntegrationProperties.CarrierSchedule.Hede(true, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/getVBilldynamic"), + Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO); + HedeTrackingClient client = new HedeTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, hede, null, null, null))); + client.initialize(); + + var response = client.query(new QueryRequest("HEDE", "CONTAINER_NO", "XYLU8251457")); + + assertThat(response.html()).contains("XYLU8251457"); + assertThat(postedBody.get()).isEqualTo( + "billno=&cntr=XYLU8251457&selecttype=1&resultlist=1¶mtype=0"); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..c352c07d2 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,109 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.provider.MatsonTrackingClient.TrackingResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MatsonCarrierScheduleProviderAdapterTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void realMatu4646247FixtureProducesTwoActualsAndEtaOnly() throws Exception { + String raw = """ + {"bookingNumber":"MATSON-REAL","containerNumber":"MATU464624","checkDigit":"7", + "originPort":"LONG BEACH","destPort":"HONOLULU","originCode":"LAX","destCode":"HIL", + "loadPort":"LONG BEACH","dischargePort":"HONOLULU","vesselETA":"08/25/26","events":[ + {"date":%d,"location":"LONG BEACH","status":"INGATE"}, + {"date":%d,"location":"LONG BEACH","status":"LOAD TO VESSEL"}]} + """.formatted(Instant.parse("2026-08-18T19:15:00Z").toEpochMilli(), + Instant.parse("2026-08-18T22:12:00Z").toEpochMilli()); + MatsonTrackingClient client = mock(MatsonTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247"))) + .thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw)); + + var result = new MatsonCarrierScheduleProviderAdapter(client) + .query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247")); + + assertThat(result.observations()).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT"); + assertThat(result.observations()).filteredOn(value -> value.actualAt() != null).hasSize(2); + assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).singleElement(); + assertThat(result.observations()).extracting(value -> value.observedAt()) + .containsOnly(result.observations().get(0).observedAt()); + assertThat(result.exceptions()).isEmpty(); + } + + @Test + void conservativelyMapsKnownMatsonEventsWithoutTreatingLoadAsDeparture() throws Exception { + String raw = """ + {"bookingNumber":"9974749","containerNumber":"MATU459895","checkDigit":"6", + "originPort":"NINGBO","destPort":"LONG BEACH","originCode":"NGB","destCode":"LAX", + "vesselETA":"08/24/26", + "loadPort":"NINGBO","dischargePort":"LONG BEACH","events":[ + {"date":1787160480000,"location":"","status":"CONTAINER ON HOLD - Customs Clearance"}, + {"date":1787033640000,"location":"NINGBO","status":"LOAD TO VESSEL"}, + {"date":1786940100000,"location":"NINGBO","status":"INGATE"}]} + """; + MatsonTrackingClient client = mock(MatsonTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956"))) + .thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw)); + MatsonCarrierScheduleProviderAdapter adapter = new MatsonCarrierScheduleProviderAdapter(client); + + var result = adapter.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956")); + + assertThat(result.outcome()).isEqualTo("SUCCESS"); + assertThat(result.observations()).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT"); + assertThat(result.observations()).filteredOn(value -> "ARRIVED_DESTINATION_PORT".equals(value.standardNode())) + .singleElement().satisfies(value -> { + assertThat(value.estimatedAt()).isNotNull(); + assertThat(value.actualAt()).isNull(); + }); + assertThat(result.exceptions()).singleElement().satisfies(value -> { + assertThat(value.exceptionCode()).isEqualTo("CUSTOMS_HOLD"); + assertThat(value.active()).isTrue(); + assertThat(value.providerStatus()).isEqualTo("CONTAINER ON HOLD - Customs Clearance"); + }); + } + + @Test + void mapsDestinationDischargeAndAvailabilityWithoutUsingEtaAsActualArrival() { + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("LOAD TO VESSEL")) + .isEqualTo("LOADED_ON_VESSEL"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("DISCHARGE FROM VESSEL")) + .isEqualTo("DISCHARGED_FROM_VESSEL"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("AVAILABLE FOR PICKUP")) + .isEqualTo("PICKUP_READY"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("OUTGATE")).isEqualTo("PICKED_UP"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("EMPTY RETURNED")) + .isEqualTo("EMPTY_RETURNED"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("PICKUP APPOINTMENT")) + .isNull(); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("CONTAINER ON HOLD - Customs Clearance")) + .isNull(); + assertThat(MatsonCarrierScheduleProviderAdapter.exceptionCode( + "CONTAINER ON HOLD - Customs Clearance")).isEqualTo("CUSTOMS_HOLD"); + } + + @Test + void emptyBusinessObjectReturnsNoFacts() throws Exception { + String raw = "{}"; + MatsonTrackingClient client = mock(MatsonTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086"))) + .thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw)); + var result = new MatsonCarrierScheduleProviderAdapter(client) + .query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086")); + assertThat(result.outcome()).isEqualTo("NO_CHANGE"); + assertThat(result.observations()).isEmpty(); + assertThat(result.exceptions()).isEmpty(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/MatsonTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonTrackingClientTest.java new file mode 100644 index 000000000..e0bd01d1d --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonTrackingClientTest.java @@ -0,0 +1,73 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class MatsonTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { + if (server != null) server.stop(0); + } + + @Test + void obtainsDynamicTtrAndCookieBeforePostingAjaxForm() throws Exception { + AtomicReference postedBody = new AtomicReference<>(); + AtomicReference postedCookie = new AtomicReference<>(); + AtomicReference requestedWith = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/page", exchange -> { + exchange.getResponseHeaders().add("Set-Cookie", "matson-session=abc123; Path=/"); + respond(exchange, 200, ""); + }); + server.createContext("/query", exchange -> { + postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + postedCookie.set(exchange.getRequestHeaders().getFirst("Cookie")); + requestedWith.set(exchange.getRequestHeaders().getFirst("X-Requested-With")); + respond(exchange, 200, "{\"containerNumber\":\"MATU459895\",\"checkDigit\":\"6\",\"events\":[]}"); + }); + server.start(); + + int port = server.getAddress().getPort(); + var matson = new IntegrationProperties.CarrierSchedule.Matson(true, + URI.create("http://127.0.0.1:" + port + "/page"), + URI.create("http://127.0.0.1:" + port + "/query"), + Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO); + MatsonTrackingClient client = new MatsonTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(matson, null, null, null, null))); + ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); + client.initialize(); + + var result = client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956")); + + assertThat(result.payload().path("containerNumber").asText()).isEqualTo("MATU459895"); + assertThat(postedBody.get()).contains("container_trackingnumber=MATU4598956") + .contains("trackingVal=tckconta").contains("ttr=dynamic123"); + assertThat(postedCookie.get()).contains("matson-session=abc123"); + assertThat(requestedWith.get()).isEqualTo("XMLHttpRequest"); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..517556227 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,71 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SmlCarrierScheduleProviderAdapterTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void sevenSampleBoundaryUsesActTypeAndLeavesFitmdoUnmapped() throws Exception { + String timeline = """ + {"TRANS_RESULT_KEY":"S","count":9,"events":[ + {"actCd":"FOTMAD","actTpCd":"A","eventDt":"2026-08-01 15:55"}, + {"actCd":"FLVMLO","actTpCd":"A","eventDt":"2026-08-04 19:45"}, + {"actCd":"FLVMDO","actTpCd":"A","eventDt":"2026-08-07 17:00"}, + {"actCd":"FUVMAD","actTpCd":"E","eventDt":"2026-08-23 14:00"}, + {"actCd":"FUVMBD","actTpCd":"E","eventDt":"2026-08-23 16:00"}, + {"actCd":"FUVMUD","actTpCd":"E","eventDt":"2026-08-24 08:00"}, + {"actCd":"FITMDO","actTpCd":"E","eventDt":"2026-08-25 08:00"}, + {"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"}, + {"actCd":"MOTYDO","actTpCd":"A","eventDt":"2026-07-28 08:00"}]} + """; + SmlTrackingClient client = mock(SmlTrackingClient.class); + when(client.configured()).thenReturn(true); + var response = new SmlTrackingClient.TrackingResponse("SMLBKG", "COP1", + objectMapper.readTree(timeline), objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline); + when(client.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))).thenReturn(response); + + var result = new SmlCarrierScheduleProviderAdapter(client) + .query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")); + + assertThat(result.observations()).hasSize(7); + assertThat(result.observations()).filteredOn(value -> value.actualAt() != null) + .extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT"); + assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).hasSize(4); + assertThat(result.observations()).extracting(value -> value.observedAt()) + .containsOnly(result.observations().get(0).observedAt()); + assertThat(result.observations()).extracting(value -> value.providerNodeCode()) + .doesNotContain("FITMDO", "MOTYDO"); + } + + @Test + void onlyActualMityadCanBecomeActualEmptyReturn() throws Exception { + String timeline = """ + {"events":[ + {"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"}, + {"actCd":"MITYAD","actTpCd":"A","eventDt":"2026-09-02 09:00"}]} + """; + var response = new SmlTrackingClient.TrackingResponse("BKG", "COP", objectMapper.readTree(timeline), + objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline); + var values = new SmlCarrierScheduleProviderAdapter(mock(SmlTrackingClient.class)) + .observations("SMCU7019890", response); + assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement() + .satisfies(value -> assertThat(value.standardNode()).isEqualTo("EMPTY_RETURNED")); + assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement(); + } + + @Test + void ambiguousFitmdoAndCargoEmptyReleaseStayUnmapped() { + assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FITMDO")).isNull(); + assertThat(SmlCarrierScheduleProviderAdapter.standardNode("MOTYDO")).isNull(); + assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FUVMUD")) + .isEqualTo("DISCHARGED_FROM_VESSEL"); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/SmlTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/SmlTrackingClientTest.java new file mode 100644 index 000000000..6ed6966e2 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/SmlTrackingClientTest.java @@ -0,0 +1,147 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SmlTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void executesTheConfirmed122125124126Chain() throws Exception { + List forms = new ArrayList<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> { + String form = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + forms.add(form); + String body = form.contains("f_cmd=122") + ? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"data\":[{\"bkgNo\":\"BKG1\",\"copNo\":\"COP1\"}]}" + : form.contains("f_cmd=125") + ? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"events\":[{\"actCd\":\"FOTMAD\",\"actTpCd\":\"A\",\"eventDt\":\"2026-08-01 15:55\"}]}" + : "{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}"; + respond(exchange, 200, body); + }); + server.start(); + + var result = new SmlCarrierScheduleProviderAdapter(client()) + .query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")); + + assertThat(result.outcome()).isEqualTo("SUCCESS"); + assertThat(result.observations()).singleElement() + .satisfies(value -> assertThat(value.sourceReference()).isEqualTo("SML:BKG1:COP1")); + assertThat(forms).hasSize(4); + assertThat(forms.get(0)).contains("f_cmd=122").contains("cntr_no=SMCU7019890"); + assertThat(forms.get(1)).contains("f_cmd=125").contains("bkg_no=BKG1").contains("cop_no=COP1"); + assertThat(forms.get(2)).contains("f_cmd=124").contains("bkg_no=BKG1"); + assertThat(forms.get(3)).contains("f_cmd=126").contains("cntr_no=SMCU7019890"); + } + + @Test + void nonJsonAndMissingBusinessKeysFailClosed() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, "schema changed")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).contains("NON_JSON")); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, + "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_KEYS_MISSING")); + } + + @Test + void providerFailureAndZeroCountAreTraceableNotFoundResults() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, + "{\"TRANS_RESULT_KEY\":\"F\",\"count\":1}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_FAILED")); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, + "{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).isEqualTo("SML_NOT_FOUND")); + } + + @Test + void nonSuccessHttpAndTimeoutRemainTraceableRetryableFailures() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 503, "{}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> { + assertThat(value.code()).isEqualTo("SML_LOOKUP_HTTP_503"); + assertThat(value.retryable()).isTrue(); + }); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> { + try { Thread.sleep(200); } + catch (InterruptedException exception) { Thread.currentThread().interrupt(); } + respond(exchange, 200, "{}"); + }); + server.start(); + assertThatThrownBy(() -> client(Duration.ofMillis(50)) + .query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> { + assertThat(value.code()).isEqualTo("SML_LOOKUP_TIMEOUT"); + assertThat(value.retryable()).isTrue(); + }); + } + + private SmlTrackingClient client() { + return client(Duration.ofSeconds(2)); + } + + private SmlTrackingClient client(Duration requestTimeout) { + var sml = new IntegrationProperties.CarrierSchedule.Sml(true, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"), + Duration.ofSeconds(2), requestTimeout, Duration.ZERO); + SmlTrackingClient client = new SmlTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, null, sml, null, null))); + ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); + client.initialize(); + return client; + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..9bb1064a6 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,106 @@ +package com.baiyue.integration.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class ZimCarrierScheduleProviderAdapterTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void v2FixtureSelectsOnlyTheExactContainerAndSeparatesActualFromPlans() throws Exception { + String fixture = """ + {"bookingReference":" BK-STRING-001 ","blReference":" BL-STRING-001 ", + "finalETA":{"etaValue":"2023-09-03T08:00:00","etaValueDate":"2023-09-03","podInd":"ETA"}, + "routeDetails":[ + {"sailingDateTz":"2023-08-10T12:00:00","arrivalDateTz":"2023-08-17T06:00:00","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"V1","voyage":"001E","leg":"1"}, + {"sailingDateDT":"2023-08-18T10:00:00","arrivalDateDT":"2023-09-02T04:00:00","portCode":"USNYC","port":"New York","country":"US","vesselName":"V2","voyage":"002E","leg":"2"}, + {"portCode":"USNYC","port":"New York","country":"US"}], + "bkBlDetails":{"consContainerList":[ + {"containerNumber":"SIBLING000001 ","containerEventsList":[ + {"activityDateTz":"2023-08-01T01:00:00","eventName":"Export Gate-In to Port of Loading","portCode":"CNNGB"}]}, + {"containerNumber":" zcsu7881970 ","containerEventsList":[ + {"activityDateTz":"2023-08-01T01:00:00","eventName":"Empty container dispatched from Port of Loading to Customer","portCode":"CNNGB"}, + {"activityDateTz":"2023-08-10T02:00:00","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"}, + {"activityDateTz":"2023-08-18T11:00:00","eventName":"Vessel departure from first Transshipment Port to next Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"}, + {"activityDateTz":"2023-08-19T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"}, + {"activityDate":"2023-08-20T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"}, + {"activityDateTz":"2023-08-21T12:00:00","eventName":"Unknown Future Event","portCode":"SGSIN"}]}]}} + """; + var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class); + var response = new ZimTrackingClient.TrackingResponse(payload, fixture); + LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 12, 0); + + var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class)) + .observations("ZCSU7881970", response, queriedAt); + + assertThat(values).hasSize(7); + assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt); + assertThat(values).filteredOn(value -> value.actualAt() != null) + .extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "IN_TRANSIT", "LOADED_ON_VESSEL", "LOADED_ON_VESSEL"); + assertThat(values).filteredOn(value -> value.estimatedAt() != null) + .extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT", + "ARRIVED_DESTINATION_PORT"); + assertThat(values).noneMatch(value -> LocalDateTime.of(2023, 8, 1, 1, 0).equals(value.actualAt())); + assertThat(values).extracting(value -> value.providerNodeCode()) + .doesNotContain("EMPTY CONTAINER DISPATCHED FROM PORT OF LOADING TO CUSTOMER", "UNKNOWN FUTURE EVENT"); + assertThat(values).allSatisfy(value -> assertThat(value.sourceReference()) + .isEqualTo("ZIM:CONTAINER:ZCSU7881970|BOOKING:BK-STRING-001|BL:BL-STRING-001")); + } + + @Test + void eventNameMappingIsAnExactExpandableWhitelist() { + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Export Gate-In to Port of Loading")).isEqualTo("AT_ORIGIN_PORT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Container was loaded at Transshipment Port to Transshipment Port")).isEqualTo("LOADED_ON_VESSEL"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Vessel departure from first Transshipment Port to next Transshipment Port")).isEqualTo("IN_TRANSIT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Container loaded at Port of Loading")).isEqualTo("LOADED_ON_VESSEL"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Vessel departure from Port of Loading")).isEqualTo("DEPARTED_ORIGIN_PORT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Container was loaded at Port of Loading to Port of Discharge")).isEqualTo("LOADED_ON_VESSEL"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Vessel departure from Port of Loading to Port of Discharge")).isEqualTo("DEPARTED_ORIGIN_PORT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Empty container dispatched from Port of Loading to Customer")).isNull(); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode("Unknown Future Event")).isNull(); + } + + @Test + void liveBkBlOnlyFixtureToleratesMissingPlansAndDoesNotAdvanceCarrierRelease() throws Exception { + String fixture = """ + {"bkBlDetails":{"consContainerList":[ + {"containerNumber":" ZCSU7205614 ","containerEventsList":[ + {"activityDateTz":"2026-08-01T01:00:00Z","eventName":"Empty container dispatched from inland point to Customer","portCode":"CNNGB"}, + {"activityDateTz":"2026-08-03T02:00:00Z","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"}, + {"activityDateTz":"2026-08-05T03:00:00Z","eventName":"Container was loaded at Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"}, + {"activityDateTz":"2026-08-06T04:00:00Z","eventName":"Vessel departure from Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"}, + {"activityDateTz":"2026-08-20T05:00:00Z","eventName":"Carrier Release","portCode":"USLAX","port":"Los Angeles","country":"US"}]}, + {"containerNumber":"ZCSU7960370","containerEventsList":[ + {"activityDateTz":"2026-08-07T00:00:00Z","eventName":"Export gate-in from Customer to Port of Loading"}]}]}} + """; + var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class); + var response = new ZimTrackingClient.TrackingResponse(payload, fixture); + + var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class)) + .observations("zcsu7205614", response, LocalDateTime.of(2026, 8, 21, 12, 0)); + + assertThat(payload.routeDetails()).isNull(); + assertThat(payload.finalETA()).isNull(); + assertThat(values).hasSize(3).allSatisfy(value -> assertThat(value.actualAt()).isNotNull()); + assertThat(values).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT"); + assertThat(values).extracting(value -> value.standardNode()).doesNotContain("PICKUP_READY"); + assertThat(values).extracting(value -> value.providerNodeCode()) + .doesNotContain("CARRIER RELEASE", "EMPTY CONTAINER DISPATCHED FROM INLAND POINT TO CUSTOMER"); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/ZimTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/ZimTrackingClientTest.java new file mode 100644 index 000000000..01e0f9de7 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/ZimTrackingClientTest.java @@ -0,0 +1,152 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ZimTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void usesOAuthTokenCacheAndContainerOnlyTracingPath() throws Exception { + Credentials credentials = Credentials.random(); + String accessToken = UUID.randomUUID().toString(); + AtomicInteger authCalls = new AtomicInteger(); + AtomicReference authForm = new AtomicReference<>(); + AtomicReference tracingPath = new AtomicReference<>(); + server = server(); + server.createContext("/authorize/v1", exchange -> { + authCalls.incrementAndGet(); authForm.set(body(exchange)); + respond(exchange, 200, tokenBody(accessToken)); + }); + server.createContext("/tracing/v2", exchange -> { + tracingPath.set(exchange.getRequestURI().getPath()); + assertThat(exchange.getRequestHeaders().getFirst("Authorization")).isEqualTo("bearer " + accessToken); + assertThat(exchange.getRequestHeaders().getFirst("Ocp-Apim-Subscription-Key")) + .isEqualTo(credentials.subscriptionKey()); + respond(exchange, 200, successBody()); + }); + server.start(); + ZimTrackingClient client = client(credentials); + + client.query(query()); client.query(query()); + + assertThat(authCalls).hasValue(1); + assertThat(authForm.get()).contains("grant_type=client_credentials", "scope=tracing") + .contains("client_id=", "client_secret="); + assertThat(tracingPath.get()).isEqualTo("/tracing/v2/ZCSU7881970"); + } + + @Test + void unauthorizedRefreshesTokenAndRetriesTracingExactlyOnce() throws Exception { + Credentials credentials = Credentials.random(); + String firstToken = UUID.randomUUID().toString(); + String secondToken = UUID.randomUUID().toString(); + AtomicInteger authCalls = new AtomicInteger(); + AtomicInteger tracingCalls = new AtomicInteger(); + server = server(); + server.createContext("/authorize/v1", exchange -> { + int call = authCalls.incrementAndGet(); respond(exchange, 200, tokenBody(call == 1 ? firstToken : secondToken)); + }); + server.createContext("/tracing/v2", exchange -> { + int call = tracingCalls.incrementAndGet(); + respond(exchange, call == 1 ? 401 : 200, call == 1 ? "{}" : successBody()); + }); + server.start(); + + client(credentials).query(query()); + + assertThat(authCalls).hasValue(2); + assertThat(tracingCalls).hasValue(2); + } + + @Test + void forbiddenIsManualAuthorizationFailureAndDoesNotEchoSecrets() throws Exception { + Credentials credentials = Credentials.random(); + server = server(); + server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString()))); + server.createContext("/tracing/v2", exchange -> respond(exchange, 403, "forbidden")); + server.start(); + + assertThatThrownBy(() -> client(credentials).query(query())) + .isInstanceOfSatisfying(ZimTrackingClient.ZimTrackingException.class, value -> { + assertThat(value.outcome()).isEqualTo("MANUAL_REQUIRED"); + assertThat(value.code()).isEqualTo("ZIM_HTTP_403"); + assertThat(value.getMessage()).doesNotContain(credentials.clientSecret()) + .doesNotContain(credentials.subscriptionKey()).doesNotContain("ZIM_NOT_FOUND"); + }); + } + + @Test + void emptyOrMissingContainerListRequiresManualHandling() throws Exception { + Credentials credentials = Credentials.random(); + AtomicReference businessBody = new AtomicReference<>( + "{\"bkBlDetails\":{\"consContainerList\":[]}}"); + server = server(); + server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString()))); + server.createContext("/tracing/v2", exchange -> respond(exchange, 200, businessBody.get())); + server.start(); + ZimTrackingClient client = client(credentials); + assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying( + ZimTrackingClient.ZimTrackingException.class, value -> assertThat(value.code()).isEqualTo("ZIM_NOT_FOUND")); + + businessBody.set("{}"); + assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying( + ZimTrackingClient.ZimTrackingException.class, + value -> assertThat(value.code()).isEqualTo("ZIM_EMPTY_RESULT")); + } + + private HttpServer server() throws IOException { return HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); } + private QueryRequest query() { return new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881970"); } + + private ZimTrackingClient client(Credentials credentials) { + String root = "http://127.0.0.1:" + server.getAddress().getPort(); + var zim = new IntegrationProperties.CarrierSchedule.Zim(true, URI.create(root + "/authorize/v1"), + URI.create(root + "/tracing/v2"), credentials.clientId(), credentials.clientSecret(), + credentials.subscriptionKey(), Duration.ofSeconds(2), Duration.ofSeconds(2), 10); + ZimTrackingClient client = new ZimTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, null, null, zim, null))); + ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); client.initialize(); return client; + } + + private static String body(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + private static String tokenBody(String token) { + return "{\"access_token\":\"" + token + "\",\"expires_in\":3600}"; + } + private static String successBody() { + return "{\"bkBlDetails\":{\"consContainerList\":[{\"containerNumber\":\" ZCSU7881970 \"}]}}"; + } + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + } + private record Credentials(String clientId, String clientSecret, String subscriptionKey) { + static Credentials random() { + return new Credentials(UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString()); + } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java new file mode 100644 index 000000000..e14c800f3 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.integration.service; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation; +import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; +import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CarrierScheduleProviderServiceTest { + @Test + void fallbackNeverPretendsThatWebsiteScrapingSucceeded() { + var service = new CarrierScheduleProviderService(List.of(new CompliantCarrierScheduleProviderAdapter())); + QueryResult result = service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567")); + assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED"); + assertThat(result.observations()).isEmpty(); + assertThat(result.exceptions()).isEmpty(); + } + + @Test + void rejectsProviderSpecificNodesThatWereNotNormalized() { + CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() { + @Override public boolean supports(String carrierCode) { return true; } + @Override public QueryResult query(QueryRequest request) { + return new QueryResult("TEST", "SUCCESS", List.of(new Observation("evt", "RAW", "UNKNOWN", + LocalDateTime.now(), null, null, null, null, null, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), List.of(), null, null, + LocalDateTime.now()); + } + }; + var service = new CarrierScheduleProviderService(List.of(invalid)); + assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567"))) + .hasMessageContaining("无效标准节点"); + } + + @Test + void rejectsProviderHoldsOutsideTheFourFrozenCodes() { + CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() { + @Override public boolean supports(String carrierCode) { return true; } + @Override public QueryResult query(QueryRequest request) { + return new QueryResult("TEST", "SUCCESS", List.of(), List.of(new ExceptionObservation( + "hold-1", "RAW HOLD", "OTHER_HOLD", true, LocalDateTime.now(), null, null, null, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null, + LocalDateTime.now()); + } + }; + var service = new CarrierScheduleProviderService(List.of(invalid)); + assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567"))) + .hasMessageContaining("无效异常事实"); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java index baa90465c..bade2d63b 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java @@ -80,7 +80,7 @@ class WeightOcrBatchServiceTest { URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1), inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L, 50, Duration.ZERO, 0); - return new IntegrationProperties(baidu, null); + return new IntegrationProperties(baidu, null, null); } @Configuration diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md new file mode 100644 index 000000000..2e012ad41 --- /dev/null +++ b/lastleg-service/AGENTS.md @@ -0,0 +1,107 @@ +# Lastleg Service 模块指引 + +## 职责与边界 + +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert` 后才生成计划并推进订单。 +- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 +- 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | +| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | +| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | +| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | +| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | +| 订单查询与更新 | `service/LastlegOrderService.java` | +| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | +| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | +| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | +| 内部令牌保护 | `security/InternalServiceTokenGuard.java` | +| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | +| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | +| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | +| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | +| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 | +| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | +| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | +| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | +| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 | +| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | +| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” | +| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | +| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | +| 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | +| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | +| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | +| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO | +| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | +| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | +| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java`、`config/LastlegRestClientProperties.java`;目标地址与内部服务令牌只来自配置,不在业务子域手工创建客户端 | +| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | +| 测试 | `src/test/java/com/baiyue/lastleg/` | +| 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 | + +## 四期开发范围路由 + +- 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 +- `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。 +- 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 +- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 + +## 数据库表索引与注释约束 + +| 业务域 | 表名 | 中文含义 | +|---|---|---| +| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 | +| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | +| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、仓位、流水、命令幂等及实收快照 | +| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 | +| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 | +| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | +| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | +| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | +| 海外安全投影 | `ll_security_event_inbox`、`ll_security_projection` | IAM 安全事件可靠收件箱与 Lastleg 本地授权投影;只允许内部服务入口访问,不作为 loading、shipping、inbound 等业务模型 | + +- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 +- `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 +- 表备注、字段备注和索引变更必须可重复执行;存量库的备注修订通过统一增量脚本落地,不依赖基线建表语句。 + +## 事件建单规则 + +- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 +- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。 +- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。 +- 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。 +- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 +- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 +- 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 +- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 +- IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。 +- Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 +- 开发基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;使用 `INSERT IGNORE`,后续 IAM version>=1 事件单调覆盖。不得扩大为历史账号运行时补齐或缺失投影默认放行。 +- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 +- 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 + +## 查询与租户约束 + +- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 +- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 +- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 +- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 +- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 +- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 +- 海外采购客户下单固定进入 `PENDING_REVIEW`;四期采购只维护采购订单、审核、入库数量、候选快照和操作流水,不提供采购出库端点、出库记录或出库数量。采购单据不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 +- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 +- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 + +## 修改与验证 + +- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 +- 安全投影边界变更同时运行 `SecurityProjectionArchitectureTest` 与 `LastlegSecurityContractTest`;`security/controller|dto|service` 只允许依赖本子域和根 `InternalServiceTokenGuard`,其他 Lastleg 业务包不得直接依赖这些投影内部包。 +- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 +- 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 +- 定向验证:`mvn -pl lastleg -am test`。 +- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md new file mode 100644 index 000000000..f0627b41b --- /dev/null +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -0,0 +1,210 @@ +# Lastleg 内部模块化依赖基线与首切片方案 + +## 1. 范围与生成方式 + +本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务流程、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在其他专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。模块内 POM 只增加由根 POM 统一版本管理的 ArchUnit 测试依赖。 + +可重复命令: + +```powershell +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph package +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format dot --graph package +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format dot --graph package +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph slice +``` + +分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 68 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。 + +## 2. 当前规模与 SCC + +| 源集 | Java 文件 | 内部类依赖 | 物理包 | 跨切片类依赖 | 模型泄漏 | Mapper 泄漏 | +|---|---:|---:|---:|---:|---:|---:| +| main | 353 | 679 | 160 | 138 | 60 | 38 | +| test only | 103 | 0 | 68 | 0 | 0 | 0 | +| main + test | 456 | 1,206 | 164 | 257 | 108 | 76 | + +主源码物理包图没有多节点 SCC。主源码逻辑切片图存在一个四节点 SCC: + +```text +dropship +inbound-pda +inventory +tailleg-handoff +``` + +合并测试源码后,出现一个由测试直接绑定横向实现包形成的物理包 SCC: + +```text +com.baiyue.lastleg.looseboard.service +com.baiyue.lastleg.relabel.service +com.baiyue.lastleg.service +com.baiyue.lastleg.transfer.service +``` + +这不是生产运行时包环。合并图的逻辑 SCC 扩大为 `dispatch-delivery`、`dropship`、`inbound-pda`、`inventory`、`procurement-trucking-looseboard`、`relabel-transfer-discard`、`tailleg-handoff` 七个切片,原因是跨域验收测试引用多个业务实现包。 + +## 3. 逻辑切片映射 + +| 逻辑切片 | 当前识别范围 | main 类数 | 跨切片出/入类边 | 当前结论 | +|---|---|---:|---:|---| +| 尾程交接 `tailleg-handoff` | 根 `controller/dto/entity/mapper/model/repository/service` 与 `shipping/` | 33 | 12/48 | 核心尾程单、事件 Inbox/Outbox 和船节点门禁被入仓、库存、换标等直接引用,位于主 SCC | +| 入仓/PDA `inbound-pda` | `inboundplan/`、`palletops/`、根 `pda/` | 100 | 53/14 | 最大切片;与尾程核心、库存和一件代发双向耦合,含大量库存 Entity/Mapper 泄漏 | +| 库存 `inventory` | `inventory/` | 26 | 3/37 | 自身出向较少,但作为多个作业域共享的 Entity/Mapper 实现被直接消费,位于主 SCC | +| 排车/派车 `dispatch-delivery` | `dispatch/`、`delivery/`、未来 `outbound/` | 46 | 20/0 | 主源码无入向 Java 类型边,但涉及明确排除的出库/派送范围,不作为首切片 | +| 换标/转运/废弃 `relabel-transfer-discard` | `relabel/`、`transfer/`、`discard/`,其 PDA 子包保持本域归属 | 61 | 31/0 | 直接使用尾程、库存和一件代发持久化模型;业务事务边界复杂 | +| 一件代发 `dropship` | `dropship/` | 12 | 4/11 | 与尾程消费及废弃库存双向耦合,位于主 SCC | +| 采购/卡车/散板 `procurement-trucking-looseboard` | `procurement/`、`trucking/`、`looseboard/` | 35 | 11/0 | 依赖尾程核心,采购另读取报价目录;仍由四期业务专项维护 | +| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 28 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 | +| 海外安全投影 `security-projection` | `security/controller`、`security/dto`、`security/service` | 6 | 1/0 | 独立于业务切片与主 SCC;唯一出向为内部 Controller 复用根 `InternalServiceTokenGuard`,无业务切片入向依赖 | + +另有 6 个 `shared-technical` 类:应用启动类、Event Bridge Client、RestClient 配置、内部令牌保护和海外权限常量。该分类不授权移动这些共享类。 + +## 4. 主源码逻辑切片边 + +| 来源 | 目标 | 类边数 | +|---|---|---:| +| dispatch-delivery | inbound-pda | 6 | +| dispatch-delivery | inventory | 4 | +| dispatch-delivery | shared-technical | 2 | +| dispatch-delivery | tailleg-handoff | 8 | +| dropship | inbound-pda | 1 | +| dropship | shared-technical | 1 | +| dropship | tailleg-handoff | 2 | +| inbound-pda | dropship | 1 | +| inbound-pda | inventory | 25 | +| inbound-pda | shared-technical | 8 | +| inbound-pda | tailleg-handoff | 19 | +| inventory | inbound-pda | 1 | +| inventory | shared-technical | 2 | +| overseas-master-data | shared-technical | 3 | +| procurement-trucking-looseboard | overseas-master-data | 1 | +| procurement-trucking-looseboard | shared-technical | 5 | +| procurement-trucking-looseboard | tailleg-handoff | 5 | +| relabel-transfer-discard | dropship | 8 | +| relabel-transfer-discard | inventory | 6 | +| relabel-transfer-discard | shared-technical | 3 | +| relabel-transfer-discard | tailleg-handoff | 14 | +| security-projection | shared-technical | 1 | +| tailleg-handoff | dropship | 2 | +| tailleg-handoff | inbound-pda | 6 | +| tailleg-handoff | inventory | 2 | +| tailleg-handoff | shared-technical | 2 | + +当前 26 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向;安全投影只允许出向 `shared-technical` 且禁止任何业务切片入向依赖。 + +## 5. Entity/DTO/Mapper 泄漏基线 + +主源码共有 60 条跨切片 Entity/DTO/Model 泄漏、38 条跨切片 Mapper 泄漏。高频边如下: + +| 来源 → 目标 | 模型 | Mapper | 代表性证据 | +|---|---:|---:|---| +| inbound-pda → inventory | 15 | 9 | 托盘、移库、盘点 Service/Mapper 直接使用 `InventoryBalance`、`InventoryPosition`、`InventoryLedger` 及其 Mapper | +| inbound-pda → tailleg-handoff | 9 | 9 | 入仓实收、卸柜完成直接使用根 `LastlegOrder*`、事件 Outbox Entity/Mapper | +| relabel-transfer-discard → dropship | 7 | 1 | 废弃直接读取一件代发箱/SKU 库存 Entity,调整服务写箱流水 Mapper | +| relabel-transfer-discard → tailleg-handoff | 6 | 6 | 换标、转运、废弃直接使用根尾程订单、明细、状态历史 Entity/Mapper | +| dispatch-delivery → tailleg-handoff | 4 | 4 | 拣货归属和派送转移直接使用根尾程订单与状态 Mapper;该范围不进入本专项首切片 | +| dispatch-delivery → inbound-pda | 4 | 2 | 派送转移直接使用入仓计划行、托盘分配 Entity/Mapper;该范围不进入本专项首切片 | +| relabel-transfer-discard → inventory | 4 | 1 | 转运直接使用库存余额/库位模型及库存命令 Mapper | + +当前泄漏体现的是业务核心对其他切片持久化实现和表模型的直接依赖。后续治理应以应用 Port 和切片自有投影替换,不共享领域 Entity;本轮不做替换。 + +## 6. 首切片选择:车辆档案 + +选择 `masterdata.vehicle` 作为首个低耦合切片候选,依据当前主源码图: + +1. 只有 5 个生产类,已经集中在单一子域;现有测试 3 个。 +2. 零跨切片入向类依赖;唯一出向类依赖为 `PlatformVehicleController -> OverseasPermissions`。 +3. 海外主数据不在 main 或 main+test 的逻辑 SCC 内,车辆子域也没有 Entity/DTO/Mapper 跨切片泄漏。 +4. 不包含跨服务 Client、RestClient 或 HTTP Gateway,不受同步依赖超时专项影响。 +5. 车辆档案明确不保存派送履约历史;首切片不触碰 `dispatch/`、`delivery/`、出库或派送代码。 +6. 当前业务面固定为平台车辆档案列表、详情、创建、编辑、状态和 CSV 导出,不需要补齐四期业务缺口。 + +报价子域虽然也不在 SCC,但被采购服务通过 `PartnerWarehouseDirectory` 入向依赖,并包含 `DomesticPartnerWarehouseClient`,因此避让。月度汇率有 8 个生产类,也比车辆档案更大。 + +## 7. 已实施目标目录与精确文件 + +首切片已按下列结构实施: + +```text +masterdata/vehicle/ + adapter/in/web/ + PlatformVehicleController.java + application/ + VehicleApplicationService.java + model/VehicleModels.java + port/out/VehicleRepository.java + adapter/out/persistence/ + VehiclePersistenceAdapter.java + VehicleMapper.java + VehicleRecord.java +``` + +精确动作草案: + +| 原文件 | 已实施动作 | +|---|---| +| `masterdata/vehicle/controller/PlatformVehicleController.java` | 移至 `adapter/in/web/`;URI、权限、HTTP/CSV 响应不变 | +| `masterdata/vehicle/dto/VehicleDtos.java` | 移至 `application/model/VehicleModels.java`;JSON 字段、校验和响应语义不变 | +| `masterdata/vehicle/service/VehicleService.java` | 移至 `application/VehicleApplicationService.java`;业务校验、乐观锁和状态语义不变,只依赖 Out Port | +| 新增 `application/port/out/VehicleRepository.java` | 定义分页、详情、锁定、创建、乐观锁更新、状态更新和导出查询所需的切片内投影;不暴露 MyBatis 类型 | +| `masterdata/vehicle/entity/Vehicle.java` | 移至 `adapter/out/persistence/VehicleRecord.java`;仅作为 `ll_vehicle` 持久化记录 | +| `masterdata/vehicle/mapper/VehicleMapper.java` | 移至 `adapter/out/persistence/VehicleMapper.java`;保持现有 SQL 与 MyBatis 语义 | +| 新增 `adapter/out/persistence/VehiclePersistenceAdapter.java` | 实现 `VehicleRepository`,隔离 MyBatis `Page`、Wrapper、Mapper 和 Record | + +允许依赖方向: + +```text +adapter.in.web -> application -> application.model + application.port.out +adapter.out.persistence -> application.port.out + application.model +``` + +Web Adapter 可继续依赖 Spring Web、common-security 和 `OverseasPermissions`。`application` 禁止依赖 Spring MVC、MyBatis/MyBatis-Plus、全局 Mapper/Entity、RestClient、其他业务切片或出库/派送类型。 + +## 8. 测试与回滚边界 + +本次迁移只修改车辆子域 5 个原生产文件、新增 2 个车辆子域文件,并定向更新: + +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java`:改为 Mock `VehicleRepository`,继续锁定公里码、乐观锁和状态规则。 +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java`:继续锁定 URI 与六项权限。 +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java`:继续锁定 `ll_vehicle` SQL、唯一索引和字段注释。 +- `scripts/analyze-lastleg-dependencies.test.mjs`:已锁定目标 7 类与 application 禁止依赖规则。 + +回滚只涉及上述车辆 Java 包移动、两个新增抽象及对应测试引用。不得修改或回滚共享 POM、`AGENTS.md`、RestClient 配置、数据库 SQL、OpenAPI、权限 SQL、接口路径、字段、状态、CSV 格式、Nacos、部署或其他业务域。若 Mapper 扫描或事务代理合同不满足,整体恢复车辆包内变更,不通过扩大全局扫描、兼容 Bean 或跨切片共享 Entity 绕过。 + +## 9. 当前只读合同 + +- `scripts/analyze-lastleg-dependencies.mjs`:生成 JSON、Markdown、逻辑切片 DOT、main/test/all 物理包 DOT 和 Tarjan SCC。 +- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 26 条 main 切片边;历史边可减少,不可增加,并锁定安全投影 6 个类的位置、单一出向与零入向。 +- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出、车辆切片边界及安全投影隔离。 +- `src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java`:用 ArchUnit 锁定投影内部只依赖本子域和根安全技术类、业务包不得反向依赖投影、DTO/Service 不依赖内部 Web Adapter。 + +最小验证: + +```powershell +node --test lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown +``` + +## 10. 首切片实施结果 + +- 旧 5 类及旧测试包已删除,没有转发兼容层;目标 7 类已由 Node 合同锁定。 +- `VehicleApplicationService` 只依赖 `VehicleModels` 与 `VehicleRepository`,不依赖 MyBatis/MyBatis-Plus、Spring MVC、RestClient、全局 Mapper/Entity 或其他 Lastleg 切片。 +- `VehiclePersistenceAdapter` 独占 MyBatis `Page`、Wrapper、Mapper 和 `VehicleRecord`,只向 Port 返回 Java 中立投影。 +- 车辆切片保持零入向依赖,唯一出向仍是 Web Adapter 使用 `OverseasPermissions`;全局 25 条切片边、60 条模型泄漏、38 条 Mapper 泄漏及主 SCC 均未增加。 +- 接口路径、六项权限、JSON 字段、校验、乐观锁、状态、CSV、SQL、表和索引保持不变。 +- 验证结果:Node 合同 4/4;`mvn -pl lastleg-service -am test-compile` 成功;车辆定向测试 5/5;`mvn -pl lastleg-service -am test` 中 lastleg-service 370 个测试,0 失败、0 错误、2 个环境验收跳过,构建成功。 + +首切片完成后停止,不进入第二切片。 + +## 11. 海外安全投影合入后的边界 + +- 用户确认安全投影保留在 `lastleg-service` 部署单元内,不拆新微服务;逻辑上独立为 `security-projection` 切片。 +- Controller 仅映射 `/internal/lastleg/security/v1/events` 与 `/validate`,使用 `@Hidden` 并在进入投影或校验服务前调用根 `InternalServiceTokenGuard`。 +- MySQL 表固定使用 Lastleg 前缀 `ll_security_event_inbox`、`ll_security_projection`;没有 `overseas_security_*` 表或独立数据库归属。 +- `loading`、`shipping`、`inboundplan` 及其他业务切片不得直接依赖 `security/controller|dto|service`;当前 Java 类型图零入向,Node 与 ArchUnit 双重锁定。 +- 安全投影不进入主逻辑 SCC,唯一出向为内部 Controller 复用共享令牌守卫,不注册为 loading/shipping/inbound 的模型、Mapper 或应用服务。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml new file mode 100644 index 000000000..d1274b7a8 --- /dev/null +++ b/lastleg-service/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml + lastleg-service + + com.baiyue.sharedcommon-event-contract${project.version} + com.baiyue.sharedbaiyue-common-openapi${project.version} +com.baomidoumybatis-plus-spring-boot3-starter + com.baiyue.sharedbaiyue-common-web${project.version} + com.baiyue.sharedbaiyue-common-security${project.version} + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config + com.alibaba.cloudspring-cloud-starter-alibaba-sentinel + org.springframework.cloudspring-cloud-starter-openfeign + org.springframework.cloudspring-cloud-starter-loadbalancer + com.alibaba.cloudspring-cloud-starter-stream-rocketmq + org.springframework.bootspring-boot-starter-actuator + com.mysqlmysql-connector-jruntime + org.springframework.bootspring-boot-starter-testtest + com.tngtech.archunitarchunittest + + org.springframework.bootspring-boot-maven-plugin + diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs new file mode 100644 index 000000000..a2488d164 --- /dev/null +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -0,0 +1,327 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const sourceRoots = { + main: path.join(moduleRoot, 'src', 'main', 'java'), + test: path.join(moduleRoot, 'src', 'test', 'java'), +}; +const internalPrefix = 'com.baiyue.lastleg.'; + +const sliceRules = [ + ['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/], + ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], + ['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/], + ['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/], + ['inventory', /(?:^|\.)inventory(?:\.|$)/], + ['dropship', /(?:^|\.)dropship(?:\.|$)/], + ['procurement-trucking-looseboard', /(?:^|\.)(?:procurement|trucking|looseboard)(?:\.|$)/], + ['overseas-master-data', /(?:^|\.)masterdata(?:\.|$)/], + ['tailleg-handoff', /(?:^|\.)(?:controller|dto|entity|mapper|model|repository|service|shipping)(?:\.|$)/], +]; + +function parseArgs(argv) { + const options = { sourceSet: 'main', format: 'json', graph: 'slice', output: null, check: null }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--source-set') options.sourceSet = argv[++index]; + else if (argument === '--format') options.format = argv[++index]; + else if (argument === '--graph') options.graph = argv[++index]; + else if (argument === '--output') options.output = argv[++index]; + else if (argument === '--check') options.check = argv[++index]; + else if (argument === '--help') { + process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--graph slice|package] [--output FILE] [--check CONTRACT.json]\n'); + process.exit(0); + } else throw new Error(`Unknown argument: ${argument}`); + } + if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`); + if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`); + if (!['slice', 'package'].includes(options.graph)) throw new Error(`Invalid graph: ${options.graph}`); + return options; +} + +function walkJavaFiles(root) { + if (!fs.existsSync(root)) return []; + const result = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(absolute); + else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute); + } + } + return result.sort(); +} + +function stripCommentsAndStrings(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/\/\/[^\r\n]*/g, ' ') + .replace(/"(?:\\.|[^"\\])*"/g, '""') + .replace(/'(?:\\.|[^'\\])*'/g, "''"); +} + +function classifySlice(fqcn) { + for (const [slice, expression] of sliceRules) { + if (expression.test(fqcn)) return slice; + } + return 'shared-technical'; +} + +function classifyKind(fqcn) { + const packageName = fqcn.slice(0, fqcn.lastIndexOf('.')); + if (/(?:^|\.)(?:client|gateway)(?:\.|$)/.test(packageName)) return 'cross-service-adapter'; + if (/(?:^|\.)(?:adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; + if (/(?:^|\.)(?:entity|dto|model)(?:\.|$)/.test(packageName)) return 'data-model'; + return 'business-application'; +} + +function parseSource(file, sourceSet) { + const raw = fs.readFileSync(file, 'utf8'); + const source = stripCommentsAndStrings(raw); + const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1]; + const typeName = path.basename(file, '.java'); + if (!packageName) throw new Error(`Missing package declaration: ${file}`); + const fqcn = `${packageName}.${typeName}`; + const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]); + return { + file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'), + sourceSet, + packageName, + typeName, + fqcn, + imports, + source, + slice: classifySlice(fqcn), + kind: classifyKind(fqcn), + }; +} + +function collectSources(sourceSet) { + const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet]; + return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set))); +} + +function resolveDependencies(sources) { + const knownTypes = new Map(sources.map(source => [source.fqcn, source])); + const typesByPackage = new Map(); + for (const source of sources) { + if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []); + typesByPackage.get(source.packageName).push(source); + } + const dependencies = []; + const seen = new Set(); + for (const source of sources) { + const candidates = new Set(); + for (const imported of source.imports) { + if (knownTypes.has(imported)) candidates.add(imported); + else if (imported.startsWith(internalPrefix)) { + const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`)); + if (owner) candidates.add(owner); + } + } + for (const samePackageType of typesByPackage.get(source.packageName) ?? []) { + if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) { + candidates.add(samePackageType.fqcn); + } + } + for (const targetFqcn of candidates) { + if (targetFqcn === source.fqcn) continue; + const key = `${source.fqcn}->${targetFqcn}`; + if (seen.has(key)) continue; + seen.add(key); + dependencies.push({ source: source.fqcn, target: targetFqcn }); + } + } + return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function aggregateEdges(dependencies, nodeOf) { + const counts = new Map(); + for (const dependency of dependencies) { + const source = nodeOf(dependency.source); + const target = nodeOf(dependency.target); + if (source === target) continue; + const key = `${source}\0${target}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts].map(([key, count]) => { + const [source, target] = key.split('\0'); + return { source, target, count }; + }).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function stronglyConnectedComponents(nodes, edges) { + const adjacency = new Map(nodes.map(node => [node, []])); + for (const edge of edges) adjacency.get(edge.source)?.push(edge.target); + const indexByNode = new Map(); + const lowLink = new Map(); + const stack = []; + const onStack = new Set(); + const components = []; + let nextIndex = 0; + function visit(node) { + indexByNode.set(node, nextIndex); + lowLink.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + for (const target of adjacency.get(node) ?? []) { + if (!indexByNode.has(target)) { + visit(target); + lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target))); + } else if (onStack.has(target)) { + lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target))); + } + } + if (lowLink.get(node) === indexByNode.get(node)) { + const component = []; + let current; + do { + current = stack.pop(); + onStack.delete(current); + component.push(current); + } while (current !== node); + components.push(component.sort()); + } + } + for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node); + return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0])); +} + +function buildReport(sourceSet) { + const sources = collectSources(sourceSet); + const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source])); + const dependencies = resolveDependencies(sources); + const packages = [...new Set(sources.map(source => source.packageName))].sort(); + const slices = [...new Set(sources.map(source => source.slice))].sort(); + const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName); + const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice); + const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice); + const modelLeaks = directCrossSliceDependencies.filter(edge => sourceByFqcn.get(edge.target).kind === 'data-model'); + const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName)); + return { + schemaVersion: 1, + generatedFrom: sourceSet, + moduleRoot: 'lastleg-service', + summary: { + javaFiles: sources.length, + internalClassDependencies: dependencies.length, + packages: packages.length, + slices: slices.length, + crossSliceClassDependencies: directCrossSliceDependencies.length, + crossSliceModelLeaks: modelLeaks.length, + crossSliceMapperLeaks: mapperLeaks.length, + }, + sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])), + classes: sources.map(({ source, ...entry }) => entry), + classDependencies: dependencies, + packageEdges, + sliceEdges, + packageSccs: stronglyConnectedComponents(packages, packageEdges), + sliceSccs: stronglyConnectedComponents(slices, sliceEdges), + directCrossSliceDependencies, + modelLeaks, + mapperLeaks, + }; +} + +function renderMarkdown(report) { + const leaks = [...report.modelLeaks, ...report.mapperLeaks] + .filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index); + return [ + '# Lastleg internal dependency report', '', + `- Source set: ${report.generatedFrom}`, + `- Java files: ${report.summary.javaFiles}`, + `- Internal class dependencies: ${report.summary.internalClassDependencies}`, + `- Packages: ${report.summary.packages}`, + `- Cross-slice class dependencies: ${report.summary.crossSliceClassDependencies}`, + `- Cross-slice model leaks: ${report.summary.crossSliceModelLeaks}`, + `- Cross-slice mapper leaks: ${report.summary.crossSliceMapperLeaks}`, + `- Package SCCs (>1 node): ${report.packageSccs.length}`, + `- Slice SCCs (>1 node): ${report.sliceSccs.length}`, + '', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|', + ...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`), + '', '## Slice SCCs', '', + ...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Package SCCs', '', + ...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Cross-slice model and mapper leaks', '', + ...(leaks.length ? leaks.map(edge => `- \`${edge.source}\` -> \`${edge.target}\``) : ['- None']), + '', + ].join('\n'); +} + +function renderDot(report, graph) { + const edges = graph === 'package' ? report.packageEdges : report.sliceEdges; + const nodes = graph === 'package' + ? [...new Set(report.classes.map(entry => entry.packageName))].sort() + : [...new Set(report.classes.map(entry => entry.slice))].sort(); + const lines = [`digraph lastleg_${graph}s {`, ' rankdir=LR;']; + for (const node of nodes) lines.push(` "${node}";`); + for (const edge of edges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); + lines.push('}', ''); + return lines.join('\n'); +} + +function checkContract(report, contractPath) { + const absolute = path.resolve(process.cwd(), contractPath); + const contract = JSON.parse(fs.readFileSync(absolute, 'utf8')); + const allowed = new Set(contract.allowedSliceEdges ?? []); + const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); + const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`); + const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); + for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { + const actualClass = classesByName.get(fqcn); + if (!actualClass) violations.push(`required class missing: ${fqcn}`); + else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); + } + const applicationRule = contract.applicationDependencyRule; + if (applicationRule) { + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix)); + for (const entry of applicationClasses) { + for (const imported of entry.imports) { + if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) { + violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`); + } + } + } + for (const edge of report.classDependencies) { + if (edge.source.startsWith(applicationRule.packagePrefix) + && edge.target.startsWith(internalPrefix) + && !edge.target.startsWith(applicationRule.allowedInternalPrefix)) { + violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`); + } + } + } + for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { + const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); + const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); + if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) { + violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`); + } + if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) { + violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`); + } + } + if (violations.length > 0) { + process.stderr.write(`Lastleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`); + process.exitCode = 1; + } +} + +const options = parseArgs(process.argv.slice(2)); +const report = buildReport(options.sourceSet); +if (options.check) checkContract(report, options.check); +const rendered = options.format === 'json' + ? `${JSON.stringify(report, null, 2)}\n` + : options.format === 'markdown' + ? renderMarkdown(report) + : renderDot(report, options.graph); +if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); +else process.stdout.write(rendered); diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs new file mode 100644 index 000000000..28469687c --- /dev/null +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs'); +const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json'); +const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle'; + +function analyze(sourceSet = 'main') { + return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { + cwd: moduleRoot, + encoding: 'utf8', + })); +} + +test('current source does not add slice dependencies beyond the Lastleg baseline', () => { + execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], { + cwd: moduleRoot, + encoding: 'utf8', + }); +}); + +test('main, test, and combined source sets expose package dependency graphs', () => { + for (const sourceSet of ['main', 'test', 'all']) { + const dot = execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'dot', '--graph', 'package'], { + cwd: moduleRoot, + encoding: 'utf8', + }); + assert.match(dot, /^digraph lastleg_packages \{/); + assert.match(dot, /com\.baiyue\.lastleg/); + } +}); + +test('vehicle master data remains the reviewed low-coupling first-slice candidate', () => { + const report = analyze(); + const classes = report.classes.filter(entry => entry.fqcn.startsWith(vehiclePrefix)); + assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord', + 'com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService', + 'com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels', + 'com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository', + ]); + const outgoing = report.classDependencies.filter(edge => edge.source.startsWith(vehiclePrefix) + && !edge.target.startsWith(vehiclePrefix)); + const incoming = report.classDependencies.filter(edge => edge.target.startsWith(vehiclePrefix) + && !edge.source.startsWith(vehiclePrefix)); + assert.deepEqual(outgoing, [{ + source: 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController', + target: 'com.baiyue.lastleg.security.OverseasPermissions', + }]); + assert.deepEqual(incoming, []); + assert.equal(report.sliceSccs.some(component => component.includes('overseas-master-data')), false); + assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false); + assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false); +}); + +test('vehicle application only depends inward and stays free of adapter frameworks', () => { + const report = analyze(); + const prefix = `${vehiclePrefix}.application`; + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); + assert.equal(applicationClasses.length, 3); + for (const entry of applicationClasses) { + assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false); + assert.equal(entry.imports.some(imported => /RestClient|\.client\./.test(imported)), false); + } + const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.lastleg.') && !edge.target.startsWith(prefix)); + assert.deepEqual(outwardDependencies, []); +}); + +test('security projection stays isolated from Lastleg business slices', () => { + const report = analyze(); + const projectionClasses = report.classes.filter(entry => entry.slice === 'security-projection'); + const projectionNames = new Set(projectionClasses.map(entry => entry.fqcn)); + assert.deepEqual(projectionClasses.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.lastleg.security.controller.LastlegSecurityController', + 'com.baiyue.lastleg.security.dto.LastlegSecurityDtos', + 'com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror', + 'com.baiyue.lastleg.security.service.LastlegSecurityProjectionService', + 'com.baiyue.lastleg.security.service.LastlegSecurityValidationService', + 'com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity', + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'security-projection'), [ + { source: 'security-projection', target: 'shared-technical', count: 1 }, + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'security-projection'), []); + assert.equal(report.sliceSccs.some(component => component.includes('security-projection')), false); + const businessConsumers = report.classDependencies.filter(edge => projectionNames.has(edge.target) + && !projectionNames.has(edge.source)); + assert.deepEqual(businessConsumers, []); +}); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java new file mode 100644 index 000000000..8fb28f552 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。 + */ +@MapperScan("com.baiyue.lastleg.mapper") +@EnableFeignClients(basePackages = "com.baiyue.lastleg") +@EnableScheduling +@SpringBootApplication(scanBasePackages = "com.baiyue") +public class BaiyueLastlegApplication { + public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java new file mode 100644 index 000000000..5a09c0bc4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.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 = "overseasContainerEventBridgeClient", + url = "${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.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/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java new file mode 100644 index 000000000..298124a59 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.config; + +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.net.http.HttpClient; + +@Component +public class LastlegRestClientFactory { + public RestClient build(RestClient.Builder builder, String baseUrl, + LastlegRestClientProperties.ClientTimeouts timeouts) { + JdkClientHttpRequestFactory requestFactory = requestFactory(timeouts); + return builder.requestFactory(requestFactory).baseUrl(baseUrl).build(); + } + + HttpClient httpClient(LastlegRestClientProperties.ClientTimeouts timeouts) { + return HttpClient.newBuilder() + .connectTimeout(timeouts.getConnectTimeout()) + .build(); + } + + JdkClientHttpRequestFactory requestFactory(LastlegRestClientProperties.ClientTimeouts timeouts) { + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient(timeouts)); + requestFactory.setReadTimeout(timeouts.getReadTimeout()); + return requestFactory; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java new file mode 100644 index 000000000..be6fd6900 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@ConfigurationProperties(prefix = "baiyue.http.clients") +public class LastlegRestClientProperties { + private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); + private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); + + public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; } + public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); } + public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; } + public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); } + public ClientTimeouts getWarehouseResource() { return warehouseResource; } + public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); } + public ClientTimeouts getPalletPhysical() { return palletPhysical; } + public void setPalletPhysical(ClientTimeouts value) { palletPhysical = required(value, "pallet-physical"); } + + private static ClientTimeouts required(ClientTimeouts value, String client) { + if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required"); + return value; + } + + public static class ClientTimeouts { + private Duration connectTimeout; + private Duration readTimeout; + + public ClientTimeouts() { } + + public ClientTimeouts(Duration connectTimeout, Duration readTimeout) { + setConnectTimeout(connectTimeout); + setReadTimeout(readTimeout); + } + + public Duration getConnectTimeout() { return connectTimeout; } + public void setConnectTimeout(Duration value) { connectTimeout = positive(value, "connect-timeout"); } + public Duration getReadTimeout() { return readTimeout; } + public void setReadTimeout(Duration value) { readTimeout = positive(value, "read-timeout"); } + + private static Duration positive(Duration value, String name) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java new file mode 100644 index 000000000..0e8e98834 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.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 = "尾程服务状态", + description = "尾程服务可用性接口" +) +@RestController +@RequestMapping("/api/platform/v1/last-leg") +public class LastlegHealthController { + @Operation( + summary = "查询服务状态", + description = "返回尾程服务边界的逻辑运行状态" + ) + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Service is available / 服务可用" + ) + @GetMapping("/status") + public ApiResponse> status() { + return ApiResponse.success(Map.of("service", "lastleg-service", "status", "UP")); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java new file mode 100644 index 000000000..7fd3375d5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.security.OverseasPermissions; +import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; +import com.baiyue.common.event.EventTypes; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; + +@RestController +@Tag(name = "Tail-leg orders") +public class LastlegOrderController { + @Resource private LoadingCompletedConsumer consumer; + @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; + @Resource private LastlegOrderService orderService; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping("/internal/lastleg/events") + @Operation(summary = "Receive a first-leg loading or actual shipping-node event", + description = "Accepts loading-completed V1/V2 and FirstLegContainerShippingNodeRecorded V1 through the internal token boundary; only monotonic actual PICKUP_READY-or-later facts open overseas PDA receiving.") + public ApiResponse receive( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope event) { + tokenGuard.verify(token); + int consumed; + if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) { + consumed = shippingNodeConsumer.consume(event); + } else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) { + consumed = consumer.consume(event); + } else { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Lastleg 不消费该事件类型: " + event.eventType()); + } + return ApiResponse.success(consumed); + } + + @GetMapping("/api/platform/v1/last-leg/orders") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "Page tail-leg order rows", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse> list( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid LastlegOrderRequests.Query query) { + return ApiResponse.success(orderService.page(platformScope(user, customerId), query)); + } + + @GetMapping("/api/platform/v1/last-leg/orders/{id}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id) { + return ApiResponse.success(orderService.detail(platformScope(user, customerId), id)); + } + + @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')") + @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT) + public ResponseEntity export( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid LastlegOrderRequests.Query query) { + byte[] body = orderService.exportCsv(platformScope(user, customerId), query); + return ResponseEntity.ok() + .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") + .body(body); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Transfer one order to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse transferOne( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) { + return ApiResponse.success(orderService.transferDestination(platformScope(user, customerId), request.withOrderId(id))); + } + + @PutMapping("/api/platform/v1/last-leg/orders/destination") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Transfer multiple orders to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse transferBatch( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid @RequestBody LastlegOrderRequests.TransferDestination request) { + return ApiResponse.success(orderService.transferDestination(platformScope(user, customerId), request)); + } + + private String platformScope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + return requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer) + ? null : requestedCustomer.trim(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java new file mode 100644 index 000000000..69a04f882 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.service.LastlegOrderService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/orders") +public class MerchantLastlegOrderController { + @Resource private LastlegOrderService orderService; + + @GetMapping + public ApiResponse> list( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid LastlegOrderRequests.Query query) { + return ApiResponse.success(orderService.page(customer(user), query)); + } + + @GetMapping("/{id}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id) { + return ApiResponse.success(orderService.detail(customer(user), id)); + } + + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java new file mode 100644 index 000000000..8d19ed2a3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.delivery; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.delivery.mapper") +public class DeliveryMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java new file mode 100644 index 000000000..b56b7d5e3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.delivery.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") +@RequestMapping("/api/platform/v1/last-leg") +@Tag(name = "Outbound and delivery") +public class DeliveryTransferController { + @Resource private DeliveryTransferService service; + + @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") + @Operation(summary = "Create one pending outbound order and one pending delivery order") + public ApiResponse transfer( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DeliveryRequests.Transfer request) { + return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request)); + } + + @GetMapping("/outbound-orders") + public ApiResponse> outboundPage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @Valid DeliveryRequests.Query query) { + return ApiResponse.success(service.outboundPage(scope(user, customerId), query)); + } + + @GetMapping("/outbound-orders/{outboundNo}") + public ApiResponse outboundDetail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) { + return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query)); + } + + @GetMapping("/delivery-orders") + public ApiResponse> deliveryPage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @Valid DeliveryRequests.Query query) { + return ApiResponse.success(service.deliveryPage(scope(user, customerId), query)); + } + + @GetMapping("/delivery-orders/{deliveryNo}") + public ApiResponse deliveryDetail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) { + return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedCustomer.trim(); + } + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java new file mode 100644 index 000000000..ac8083c6f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.delivery.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public final class DeliveryRequests { + private DeliveryRequests() {} + + public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String documentNo, String warehouseId, String status) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size, + String destinationWarehouseCode) { + public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java new file mode 100644 index 000000000..b763efe88 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.delivery.dto; + +import java.time.LocalDateTime; + +public final class DeliveryResponses { + private DeliveryResponses() {} + + public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus, + String deliveryNo, String deliveryStatus, int palletCount, + int packageCount, boolean replayed) {} + + public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, + String status, int palletCount, int packageCount, LocalDateTime createdAt) {} + + public record DeliveryRow(Long id, String deliveryNo, String outboundNo, String dispatchNo, + String overseasWarehouseId, String status, int palletCount, + int packageCount, LocalDateTime createdAt) {} + + public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java new file mode 100644 index 000000000..ee1b4f809 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.delivery.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; + +/** Delivery document linked one-to-one to its outbound document and dispatch. */ +@Data +@TableName("ll_delivery_order") +public class DeliveryOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String deliveryNo; + private Long outboundOrderId; + private String outboundNo; + private Long dispatchOrderId; + private String dispatchNo; + private String overseasWarehouseId; + private String status; + private Integer packageCount; + private Integer palletCount; + private String idempotencyKey; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java new file mode 100644 index 000000000..0f438791f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.delivery.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; + +/** Outbound document created when a dispatch is transferred to delivery. */ +@Data +@TableName("ll_outbound_order") +public class OutboundOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String outboundNo; + private Long dispatchOrderId; + private String dispatchNo; + private String overseasWarehouseId; + private String status; + private Integer packageCount; + private Integer palletCount; + private String idempotencyKey; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java new file mode 100644 index 000000000..8145f8f82 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.delivery.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; + +/** Pallet-level immutable snapshot belonging to one outbound document. */ +@Data +@TableName("ll_outbound_pallet_item") +public class OutboundPalletItem { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long outboundOrderId; + private Long warehousePalletId; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private Integer packageCount; + private String orderRefs; + private String inboundCode; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java new file mode 100644 index 000000000..7d2641cf5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface DeliveryOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1") + DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE") + DeliveryOrder lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java new file mode 100644 index 000000000..e16968f70 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface OutboundOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1") + OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") + OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java new file mode 100644 index 000000000..32bb24aa1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +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 OutboundPalletItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_pallet_item WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id") + List findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java new file mode 100644 index 000000000..57ec1814b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -0,0 +1,205 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Service +public class DeliveryTransferService { + @Resource private DispatchOrderMapper dispatchOrders; + @Resource private DispatchItemMapper dispatchItems; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundPallets; + @Resource private DeliveryOrderMapper deliveryOrders; + @Resource private InboundPlanPalletAllocationMapper planAllocations; + @Resource private InboundPlanLineMapper planLines; + @Resource private LastlegOrderMapper tailOrders; + @Resource private LastlegOrderStatusHistoryMapper histories; + + @Transactional + public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId, + DeliveryRequests.Transfer request) { + DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo); + if (found == null) throw notFound("Dispatch order not found"); + DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId()); + OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); + if (replay != null) { + if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch"); + return result(replay, requireDelivery(customerId, replay.getId()), true); + } + OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId()); + if (existing != null) return result(existing, requireDelivery(customerId, existing.getId()), true); + if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) { + throw conflict("Dispatch cannot be transferred to delivery in its current status"); + } + List items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId()); + if (items.isEmpty()) throw conflict("Dispatch has no pallets"); + if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { + throw conflict("All dispatch pallets must be received before transfer to delivery"); + } + if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required"); + + LocalDateTime now = LocalDateTime.now(); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); + outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId()); + outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); + outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); + outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); + outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); + try { + outboundOrders.insert(outbound); + } catch (DuplicateKeyException e) { + // The unique keys are the final concurrent guard. A client retry must observe + // the document created by the winning transaction rather than receive a false failure. + OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); + if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) { + return result(concurrent, requireDelivery(customerId, concurrent.getId()), true); + } + concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId()); + if (concurrent != null) { + return result(concurrent, requireDelivery(customerId, concurrent.getId()), true); + } + throw conflict("Dispatch was concurrently transferred to delivery"); + } + + for (DispatchItem source : items) { + OutboundPalletItem item = new OutboundPalletItem(); + item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); + item.setWarehousePalletId(source.getWarehousePalletId()); item.setPalletNo(source.getPalletNo()); + item.setDestinationWarehouseId(source.getDestinationWarehouseId()); + item.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); + item.setDestinationWarehouseName(source.getDestinationWarehouseName()); + item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); + item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now); + outboundPallets.insert(item); + } + + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); + delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId()); + delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); + delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); + delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setStatus("WAIT_DELIVERY"); + delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount()); + delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId); + delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); + bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); + return result(outbound, delivery, false); + } + + public PageResult outboundPage(String customerId, DeliveryRequests.Query query) { + Page page = outboundOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(OutboundOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo()) + .eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId()) + .eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status()) + .orderByDesc(OutboundOrder::getCreatedAt)); + return page(page, page.getRecords().stream().map(this::outboundRow).toList()); + } + + public DeliveryResponses.OutboundDetail outboundDetail(String customerId, String outboundNo, + DeliveryRequests.PalletQuery query) { + OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo); + if (outbound == null) throw notFound("Outbound order not found"); + DeliveryOrder delivery = requireDelivery(customerId, outbound.getId()); + return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery)); + } + + public PageResult deliveryPage(String customerId, DeliveryRequests.Query query) { + Page page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(DeliveryOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.documentNo()), DeliveryOrder::getDeliveryNo, query.documentNo()) + .eq(StringUtils.hasText(query.warehouseId()), DeliveryOrder::getOverseasWarehouseId, query.warehouseId()) + .eq(StringUtils.hasText(query.status()), DeliveryOrder::getStatus, query.status()) + .orderByDesc(DeliveryOrder::getCreatedAt)); + return page(page, page.getRecords().stream().map(this::deliveryRow).toList()); + } + + public DeliveryResponses.OutboundDetail deliveryDetail(String customerId, String deliveryNo, + DeliveryRequests.PalletQuery query) { + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("Delivery order not found"); + OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId()); + if (outbound == null || !customerId.equals(outbound.getCustomerId())) throw notFound("Outbound order not found"); + return outboundDetail(customerId, outbound.getOutboundNo(), query); + } + + private void bindTailOrders(String customerId, List items, String outboundNo, String deliveryNo, + String remark, LocalDateTime now) { + Set orderIds = new LinkedHashSet<>(); + items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(orderIds::add); + List palletIds = items.stream().map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).distinct().toList(); + if (!palletIds.isEmpty()) { + List allocations = planAllocations.selectList( + new LambdaQueryWrapper().in(InboundPlanPalletAllocation::getPalletId, palletIds)); + List lineIds = allocations.stream().map(InboundPlanPalletAllocation::getLineId) + .filter(Objects::nonNull).distinct().toList(); + if (!lineIds.isEmpty()) { + planLines.selectBatchIds(lineIds).stream().map(InboundPlanLine::getOrderId) + .filter(Objects::nonNull).forEach(orderIds::add); + } + } + if (orderIds.isEmpty()) return; + for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, orderIds))) { + String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo); + order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now); + order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY"); + tailOrders.updateById(order); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId()); + history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND"); + history.setEventId("TRANSFER_DELIVERY:" + deliveryNo); + history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents"); + history.setCreatedAt(now); histories.insert(history); + } + } + + private DeliveryOrder requireDelivery(String customerId, Long outboundId) { + DeliveryOrder value = deliveryOrders.lockByOutbound(customerId, outboundId); + if (value == null) throw new IllegalStateException("Delivery document is missing for outbound order"); + return value; + } + private DeliveryResponses.TransferResult result(OutboundOrder o, DeliveryOrder d, boolean replayed) { + return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(), + d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed); + } + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); } + private static PageResult page(Page page, List records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/DiscardMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/DiscardMapperConfiguration.java new file mode 100644 index 000000000..bf22c3ae2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/DiscardMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.discard; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.discard.mapper") +public class DiscardMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java new file mode 100644 index 000000000..e0a1a3284 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.discard.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/cargo-discards") +public class CargoDiscardController { + @Resource private CargoDiscardService service; + + @GetMapping("/inventory-candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @RequestParam(required = false) String inboundCode) { + return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) { + return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); + } + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid CargoDiscardRequests.Query query) { + return ApiResponse.success(service.page(scope(user, customerId), query)); + } + + @GetMapping("/{discardNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String discardNo) { + return ApiResponse.success(service.detail(scope(user, customerId), discardNo)); + } + + @PostMapping("/{discardNo}/review") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String discardNo, + @Valid @RequestBody CargoDiscardRequests.Review request) { + requireApprovalPermission(user); + return ApiResponse.success(service.review(scope(user, customerId), discardNo, operator(user), request)); + } + + private String scope(AuthenticatedUser user, String requested) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requested == null || requested.isBlank() || "0".equals(requested)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requested.trim(); + } + return String.valueOf(user.tenantId()); + } + + private void requireApprovalPermission(AuthenticatedUser user) { + if (user == null || user.permissions() == null + || !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required"); + } + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java new file mode 100644 index 000000000..fc5577461 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.discard.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/cargo-discards") +public class MerchantCargoDiscardController { + @Resource private CargoDiscardService service; + @GetMapping("/inventory-candidates") + public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); } + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } + @GetMapping("/{discardNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); } + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java new file mode 100644 index 000000000..415d0f873 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.discard.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class CargoDiscardRequests { + private CargoDiscardRequests() {} + + public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Item(@NotNull Long orderId, @NotBlank @Size(max = 128) String skuCode, + @Positive int discardPackages) {} + + public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String discardNo, String inboundCode, String overseasWarehouseId, + String status, LocalDateTime createdFrom, LocalDateTime createdTo) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java new file mode 100644 index 000000000..7d906b439 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.discard.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class CargoDiscardResponses { + private CargoDiscardResponses() {} + + public record Candidate(Long orderId, String inboundCode, String inboundOrderNo, + String overseasWarehouseId, String overseasWarehouseName, + LocalDateTime inboundCreatedAt, long availablePackages, + List skus) {} + public record CandidateSku(String skuCode, String productName, long availablePackages) {} + + public record Summary(Long id, String discardNo, String inboundCodes, + String overseasWarehouseId, String overseasWarehouseName, + int totalPackages, BigDecimal totalFee, String currency, String status, + String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {} + + public record Item(Long id, Long orderId, String inboundCode, String skuCode, + String productName, int availablePackagesSnapshot, int discardPackages) {} + + public record History(String fromStatus, String toStatus, String operatorId, + String remark, LocalDateTime createdAt) {} + + public record Detail(Summary summary, BigDecimal feePerPackage, String remark, + String reviewedBy, String reviewRemark, List items, + List histories) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java new file mode 100644 index 000000000..369ece1f4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.discard.entity; + +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_cargo_discard_application") +public class CargoDiscardApplication { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String discardNo; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String inboundCodes; + private Integer totalPackages; + private BigDecimal feePerPackage; + private BigDecimal totalFee; + private String currency; + private String status; + private String remark; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private String reviewedBy; + private String reviewRemark; + private LocalDateTime reviewedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java new file mode 100644 index 000000000..b8319c505 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.discard.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; + +@Data +@TableName("ll_cargo_discard_carton") +public class CargoDiscardCarton { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private Long itemId; + private String customerId; + private Long cartonId; + private String cartonMark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java new file mode 100644 index 000000000..7912792b2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.discard.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; + +@Data +@TableName("ll_cargo_discard_item") +public class CargoDiscardItem { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private Long orderId; + private String inboundCode; + private Long profileId; + private Long skuInventoryId; + private String skuCode; + private String productName; + private Integer availablePackagesSnapshot; + private Integer discardPackages; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java new file mode 100644 index 000000000..e2caa34bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.discard.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; + +@Data +@TableName("ll_cargo_discard_status_history") +public class CargoDiscardStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private String fromStatus; + private String toStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java new file mode 100644 index 000000000..64a70ec3e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface CargoDiscardApplicationMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1") + CargoDiscardApplication findNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo); + + @Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + CargoDiscardApplication findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1 FOR UPDATE") + CargoDiscardApplication lockNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java new file mode 100644 index 000000000..e672a85ca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +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 CargoDiscardCartonMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_carton WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java new file mode 100644 index 000000000..93a8178cc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface CargoDiscardInventoryMapper { + @Select("") + List candidates(@Param("customerId") String customerId, + @Param("inboundCode") String inboundCode); + + @Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND sku_code=#{skuCode} AND status='AVAILABLE'") + long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("skuCode") String skuCode); + + @Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND profile_id=#{profileId} AND sku_code=#{skuCode} AND status='AVAILABLE'") + long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("profileId") Long profileId, @Param("skuCode") String skuCode); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND sku_code=#{skuCode} AND status='AVAILABLE' ORDER BY profile_id,carton_sequence,id LIMIT #{limit} FOR UPDATE") + List lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("skuCode") String skuCode, @Param("limit") int limit); + + @Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}") + int changeCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId, + @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND id=#{cartonId} LIMIT 1 FOR UPDATE") + DropshipCarton lockCarton(@Param("customerId") String customerId, @Param("cartonId") Long cartonId); + + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") + DropshipSkuInventory lockSkuInventory(@Param("customerId") String customerId, @Param("id") Long id); + + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND profile_id=#{profileId} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE") + DropshipSkuInventory lockSkuInventoryDimension(@Param("customerId") String customerId, + @Param("orderId") Long orderId, + @Param("profileId") Long profileId, + @Param("skuCode") String skuCode); + + @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}") + int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id, + @Param("packages") int packages); + + @Select("SELECT package_count FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id}") + Long skuPackages(@Param("customerId") String customerId, @Param("id") Long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java new file mode 100644 index 000000000..19ea10c5c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +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 CargoDiscardItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_item WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY inbound_code,sku_code,profile_id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java new file mode 100644 index 000000000..816a41e29 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory; +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 CargoDiscardStatusHistoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_status_history WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java new file mode 100644 index 000000000..90abc3e1b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.discard.model; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class CargoDiscardCandidateRow { + private Long orderId; + private String inboundCode; + private String inboundOrderNo; + private String overseasWarehouseId; + private String overseasWarehouseName; + private LocalDateTime createdAt; + private Long profileId; + private Long skuInventoryId; + private String skuCode; + private String productName; + private Long availablePackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java new file mode 100644 index 000000000..1691f7dda --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.discard.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +import jakarta.annotation.Resource; +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.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** Final inventory mutation for a warehouse-approved cargo discard. */ +@Service +public class CargoDiscardInventoryAdjustmentService { + @Resource private CargoDiscardInventoryMapper inventory; + @Resource private DropshipCartonLedgerMapper cartonLedgers; + + @Transactional(propagation = Propagation.MANDATORY) + public AdjustmentResult discard(String customerId, CargoDiscardApplication application, + List items, + Map> linksByItem, + String operatorId, LocalDateTime occurredAt) { + long beforePackages = 0; + long discardedPackages = 0; + long afterPackages = 0; + for (CargoDiscardItem item : items) { + DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId()); + if (sku == null) throw conflict("SKU inventory is missing during discard approval: " + item.getSkuCode()); + long before = value(sku.getPackageCount()); + int discarded = item.getDiscardPackages(); + if (before < discarded || inventory.deductSkuInventory(customerId, sku.getId(), discarded) != 1) { + throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode()); + } + List links = linksByItem.getOrDefault(item.getId(), List.of()); + if (links.size() != discarded) throw conflict("Reserved discard cartons are incomplete"); + for (CargoDiscardCarton link : links) { + DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId()); + if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(), + "DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval"); + cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt)); + } + Long persistedAfter = inventory.skuPackages(customerId, sku.getId()); + long expectedAfter = before - discarded; + if (persistedAfter == null || persistedAfter != expectedAfter) { + throw conflict("Cargo discard package conservation check failed for SKU " + item.getSkuCode()); + } + beforePackages = Math.addExact(beforePackages, before); + discardedPackages = Math.addExact(discardedPackages, discarded); + afterPackages = Math.addExact(afterPackages, persistedAfter); + } + if (beforePackages - discardedPackages != afterPackages + || discardedPackages != application.getTotalPackages()) { + throw conflict("Cargo discard application package conservation check failed"); + } + return new AdjustmentResult(beforePackages, discardedPackages, afterPackages); + } + + private DropshipCartonLedger discardFact(String customerId, CargoDiscardApplication application, + CargoDiscardItem item, DropshipCarton carton, + String operatorId, LocalDateTime occurredAt) { + DropshipCartonLedger fact = new DropshipCartonLedger(); + fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId()); + fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode()); + fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L); + fact.setBusinessOrderNo(application.getDiscardNo()); + fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId()); + fact.setRemark("Warehouse discard approved by " + operatorId + remarkSuffix(application.getRemark())); + fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt); + return fact; + } + + private static long value(Long value) { return value == null ? 0L : value; } + private static String remarkSuffix(String value) { + return value == null || value.isBlank() ? "" : "; " + value.trim(); + } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + + public record AdjustmentResult(long beforePackages, long discardedPackages, long afterPackages) { + public String auditRemark(String reviewRemark) { + String prefix = reviewRemark == null || reviewRemark.isBlank() ? "" : reviewRemark.trim() + "; "; + return prefix + "inventoryBefore=" + beforePackages + ", discarded=" + discardedPackages + + ", inventoryAfter=" + afterPackages; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java new file mode 100644 index 000000000..2f366c605 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -0,0 +1,303 @@ +package com.baiyue.lastleg.discard.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory; +import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper; +import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class CargoDiscardService { + public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve"; + public static final String PENDING_REVIEW = "PENDING_REVIEW"; + public static final String APPROVED = "APPROVED"; + public static final String REJECTED = "REJECTED"; + private static final Set STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED); + + @Resource private CargoDiscardApplicationMapper applications; + @Resource private CargoDiscardItemMapper items; + @Resource private CargoDiscardCartonMapper discardCartons; + @Resource private CargoDiscardStatusHistoryMapper histories; + @Resource private CargoDiscardInventoryMapper inventory; + @Resource private CargoDiscardInventoryAdjustmentService adjustments; + @Resource private LastlegOrderMapper orders; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}") + private BigDecimal feePerPackage = new BigDecimal("5.00"); + + public List candidates(String customerId, String inboundCode) { + List rows = inventory.candidates(customerId, trim(inboundCode)); + Map grouped = new LinkedHashMap<>(); + for (CargoDiscardCandidateRow row : rows) { + CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row)); + value.add(row); + } + return grouped.values().stream().map(CandidateAccumulator::response).toList(); + } + + @Transactional + public CargoDiscardResponses.Detail create(String customerId, String operatorId, + CargoDiscardRequests.Create request) { + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + CargoDiscardApplication replay = applications.findKey(customerId, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) { + throw conflict("Idempotency key was reused with different cargo discard data"); + } + return detail(customerId, replay.getDiscardNo()); + } + Set requestedDimensions = new LinkedHashSet<>(); + Map orderCache = new HashMap<>(); + String warehouseId = null; + String warehouseName = null; + int total = 0; + for (CargoDiscardRequests.Item input : request.items()) { + String dimension = input.orderId() + ":" + input.skuCode().trim(); + if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension); + LastlegOrder order = requireDropshipOrder(customerId, input.orderId()); + orderCache.put(order.getId(), order); + if (warehouseId == null) { + warehouseId = required(order.getOverseasWarehouseId(), "Order has no operating overseas warehouse"); + warehouseName = trim(order.getOverseasWarehouseName()); + } else if (!warehouseId.equals(order.getOverseasWarehouseId())) { + throw conflict("One cargo discard application cannot span operating warehouses"); + } + total = Math.addExact(total, input.discardPackages()); + } + + LocalDateTime now = LocalDateTime.now(); + CargoDiscardApplication application = new CargoDiscardApplication(); + application.setId(IdWorker.getId()); application.setCustomerId(customerId); + application.setDiscardNo("FH" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + application.getId()); + application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName); + application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode) + .distinct().sorted().collect(java.util.stream.Collectors.joining(","))); + application.setTotalPackages(total); application.setFeePerPackage(feePerPackage); + application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total))); + application.setCurrency("USD"); application.setStatus(PENDING_REVIEW); + application.setRemark(trim(request.remark())); application.setIdempotencyKey(key); + application.setRequestHash(requestHash); application.setCreatedBy(operatorId); + application.setCreatedAt(now); application.setUpdatedAt(now); + applications.insert(application); + + for (CargoDiscardRequests.Item input : request.items()) { + reserve(customerId, application, orderCache.get(input.orderId()), input, now); + } + history(customerId, application.getId(), null, PENDING_REVIEW, operatorId, request.remark(), now); + return detail(customerId, application.getDiscardNo()); + } + + public PageResult page(String customerId, CargoDiscardRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(CargoDiscardApplication::getCustomerId, customerId) + .like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo()) + .like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode()) + .eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId, + query.overseasWarehouseId()) + .eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status()) + .ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo()) + .orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId); + Page result = applications.selectPage(Page.of(query.page(), query.size()), wrapper); + List records = result.getRecords().stream().map(this::summary).toList(); + return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages()); + } + + public CargoDiscardResponses.Detail detail(String customerId, String discardNo) { + CargoDiscardApplication application = applications.findNo(customerId, discardNo); + if (application == null) throw notFound("Cargo discard application not found"); + List itemViews = items.listApplication(customerId, application.getId()).stream() + .map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(), + value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages())) + .toList(); + List historyViews = histories.listApplication(customerId, application.getId()).stream() + .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), + value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList(); + return new CargoDiscardResponses.Detail(summary(application), application.getFeePerPackage(), + application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews); + } + + @Transactional + public CargoDiscardResponses.Detail review(String customerId, String discardNo, String operatorId, + CargoDiscardRequests.Review request) { + String decision = request.decision().trim().toUpperCase(); + String next = switch (decision) { + case "APPROVE", APPROVED -> APPROVED; + case "REJECT", REJECTED -> REJECTED; + default -> throw badRequest("Review decision must be APPROVE or REJECT"); + }; + CargoDiscardApplication application = applications.lockNo(customerId, discardNo); + if (application == null) throw notFound("Cargo discard application not found"); + if (next.equals(application.getStatus())) return detail(customerId, discardNo); + if (!PENDING_REVIEW.equals(application.getStatus())) throw conflict("Cargo discard application was already reviewed"); + + List applicationItems = items.listApplication(customerId, application.getId()); + List links = discardCartons.listApplication(customerId, application.getId()); + Map> linksByItem = links.stream() + .collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId)); + String historyRemark = request.remark(); + if (APPROVED.equals(next)) { + CargoDiscardInventoryAdjustmentService.AdjustmentResult result = adjustments.discard( + customerId, application, applicationItems, linksByItem, operatorId, now()); + historyRemark = result.auditRemark(request.remark()); + } else { + release(customerId, links); + } + + LocalDateTime reviewedAt = now(); + application.setStatus(next); application.setReviewedBy(operatorId); + application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt); + application.setUpdatedAt(reviewedAt); applications.updateById(application); + history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt); + return detail(customerId, discardNo); + } + + private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order, + CargoDiscardRequests.Item input, LocalDateTime now) { + String skuCode = input.skuCode().trim(); + if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) { + throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode()); + } + List selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages()); + if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request"); + Map> byProfile = selected.stream() + .collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new, + java.util.stream.Collectors.toList())); + for (Map.Entry> entry : byProfile.entrySet()) { + DropshipSkuInventory sku = inventory.lockSkuInventoryDimension(customerId, order.getId(), entry.getKey(), skuCode); + if (sku == null) throw conflict("SKU inventory disappeared while reserving cargo"); + CargoDiscardItem item = new CargoDiscardItem(); + item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId); + item.setOrderId(order.getId()); item.setInboundCode(order.getInboundCode()); item.setProfileId(entry.getKey()); + item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName()); + item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile( + customerId, order.getId(), entry.getKey(), skuCode))); + item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item); + for (DropshipCarton carton : entry.getValue()) { + if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) { + throw conflict("Cargo was reserved concurrently; retry the discard request"); + } + CargoDiscardCarton link = new CargoDiscardCarton(); + link.setId(IdWorker.getId()); link.setApplicationId(application.getId()); link.setItemId(item.getId()); + link.setCustomerId(customerId); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); + link.setCreatedAt(now); discardCartons.insert(link); + } + } + } + + private void release(String customerId, List links) { + for (CargoDiscardCarton link : links) { + if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) { + throw conflict("Reserved cargo status changed before rejection"); + } + } + } + + private LastlegOrder requireDropshipOrder(String customerId, Long orderId) { + LastlegOrder order = orders.selectById(orderId); + if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found"); + if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order"); + } + return order; + } + + private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) { + return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(), + value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(), + value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), + value.getReviewedAt()); + } + + private void history(String customerId, Long applicationId, String from, String to, String operatorId, + String remark, LocalDateTime createdAt) { + CargoDiscardStatusHistory value = new CargoDiscardStatusHistory(); + value.setId(IdWorker.getId()); value.setApplicationId(applicationId); value.setCustomerId(customerId); + value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(operatorId); + value.setRemark(trim(remark)); value.setCreatedAt(createdAt); histories.insert(value); + } + + private void validateQuery(CargoDiscardRequests.Query query) { + if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status())) throw badRequest("Unsupported discard status"); + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { + throw badRequest("createdFrom must not be after createdTo"); + } + } + + private String hash(Object value) { + try { + byte[] json = objectMapper.writeValueAsBytes(value); + return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("Cannot hash cargo discard request", exception); + } + } + + private static LocalDateTime now() { return LocalDateTime.now(); } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + + private static final class CandidateAccumulator { + private final CargoDiscardCandidateRow first; + private final Map skus = new LinkedHashMap<>(); + private CandidateAccumulator(CargoDiscardCandidateRow first) { this.first = first; } + private void add(CargoDiscardCandidateRow row) { + skus.computeIfAbsent(row.getSkuCode(), ignored -> new CandidateSkuAccumulator(row.getSkuCode(), row.getProductName())) + .packages += row.getAvailablePackages() == null ? 0 : row.getAvailablePackages(); + } + private CargoDiscardResponses.Candidate response() { + List skuViews = skus.values().stream() + .map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList(); + long packages = skuViews.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum(); + return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(), + first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), packages, skuViews); + } + } + private static final class CandidateSkuAccumulator { + private final String skuCode; + private final String productName; + private long packages; + private CandidateSkuAccumulator(String skuCode, String productName) { this.skuCode = skuCode; this.productName = productName; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java new file mode 100644 index 000000000..9a4339977 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dispatch; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.dispatch.mapper") +public class DispatchMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java new file mode 100644 index 000000000..d6de4b587 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +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.dispatch.pda.controller.DispatchPdaController; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") +@RequestMapping("/api/platform/v1/last-leg/dispatches") +@Tag(name = "Tail-leg vehicle dispatch") +public class DispatchController { + @Resource private DispatchService service; + @Resource private DispatchPdaWorkflowService pdaWorkflow; + + @PutMapping("/{dispatchNo}") + @Operation(summary = "Edit vehicle, driver, appointment, route and departure data") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchRequests.Update request) { + return ApiResponse.success(service.update(scope(user, customerId), dispatchNo, request)); + } + + @DeleteMapping("/{dispatchNo}") + @Operation(summary = "Cancel a pending dispatch and release its pallet locks") + public ApiResponse cancel( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String dispatchNo) { + service.cancel(scope(user, customerId), dispatchNo); + return ApiResponse.success(null); + } + + @PostMapping("/{dispatchNo}/depart") + @Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed") + public ApiResponse depart( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchPdaRequests.Depart request) { + requirePdaOperate(user); + String scopedTenant = scope(user, customerId); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform request must specify target tenant"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } + + private void requirePdaOperate(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(DispatchPdaController.OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + DispatchPdaController.OPERATE_PERMISSION + " permission is required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java new file mode 100644 index 000000000..c31d84a80 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +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.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@ConditionalOnProperty(name = "lastleg.dispatch-query-api.enabled", havingValue = "true") +@RequestMapping("/api/platform/v1/last-leg/dispatches") +@Tag(name = "Tail-leg vehicle dispatch query") +public class DispatchQueryController { + @Resource private DispatchService service; + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "Page dispatch orders") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid DispatchRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.platformPage(query)); + } + + @GetMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "Get dispatch details and all pallet destinations") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo) { + requirePlatform(user); + return ApiResponse.success(service.platformDetail(dispatchNo)); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java new file mode 100644 index 000000000..5c1f97f87 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -0,0 +1,142 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +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.PostMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@ConditionalOnProperty(name = "lastleg.smart-dispatch-api.enabled", havingValue = "true") +@RequestMapping("/api/platform/v1/last-leg/dispatches") +@Tag(name = "Tail-leg smart dispatch") +public class SmartDispatchController { + @Resource private DispatchService service; + + @GetMapping("/candidates") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch", + description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) List planIds, + @RequestParam(required = false) String orderNo, + @RequestParam(required = false) String fbaWarehouseCode, + @RequestParam(required = false) String sourceStage, + @RequestParam(required = false) String draftToken) { + requirePlatform(user); + if (planIds == null || planIds.isEmpty()) { + return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken)); + } + return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo, + fbaWarehouseCode, sourceStage, draftToken)); + } + + @GetMapping("/drafts") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + public ApiResponse> drafts(@AuthenticationPrincipal AuthenticatedUser user) { + requirePlatform(user); return ApiResponse.success(service.activeDrafts(operatorId(user))); + } + + @PostMapping("/drafts/snapshot") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + public ApiResponse saveDraft(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.DraftSnapshot request) { + requirePlatform(user); service.saveDraftSnapshot(operatorId(user), request); return ApiResponse.success(null); + } + + @PostMapping("/pre-arrange") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets", + description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH) + public ApiResponse preArrange( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.PreArrange request) { + requirePlatform(user); + String scopedCustomer = service.resolveCustomerScopeForPallets(request.planPalletIds()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request)); + } + + @PostMapping("/draft-locks/lock") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + @Operation(summary = "Immediately lock one pallet in the current intelligent dispatch draft") + public ApiResponse lockDraftPallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.DraftPallet request) { + requirePlatform(user); + String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId())); + service.lockDraftPallet(customerId, operatorId(user), request); + return ApiResponse.success(null); + } + + @PostMapping("/drafts/containers") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + public ApiResponse createContainer(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.DraftContainer request) { + requirePlatform(user); return ApiResponse.success(service.createDraftContainer(operatorId(user), request)); + } + + @DeleteMapping("/drafts/{draftToken}/containers/{containerId}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + public ApiResponse deleteContainer(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String draftToken, @PathVariable Long containerId) { + requirePlatform(user); service.deleteDraftContainer(operatorId(user), draftToken, containerId); return ApiResponse.success(null); + } + + @PostMapping("/draft-locks/unlock") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + @Operation(summary = "Unlock one pallet removed from the current intelligent dispatch draft") + public ApiResponse unlockDraftPallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.DraftPallet request) { + requirePlatform(user); + String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId())); + service.unlockDraftPallet(customerId, operatorId(user), request); + return ApiResponse.success(null); + } + + @PostMapping("/draft-locks/release") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + @Operation(summary = "Release every pallet lock held by the current intelligent dispatch draft") + public ApiResponse releaseDraft( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.DraftRelease request) { + requirePlatform(user); + service.releaseDraft(operatorId(user), request.draftToken()); + return ApiResponse.success(null); + } + + private String operatorId(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchContainerResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchContainerResponse.java new file mode 100644 index 000000000..5f50ba7f6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchContainerResponse.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.dispatch.dto; + +import java.util.List; + +public record DispatchContainerResponse(String id, int capacity, List palletIds) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftResponse.java new file mode 100644 index 000000000..9f7a3f5cc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftResponse.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.dispatch.dto; + +import java.util.List; +public record DispatchDraftResponse(String draftToken, String planIdsJson, String containersJson, List containers) { + public DispatchDraftResponse(String draftToken, String planIdsJson, String containersJson) { this(draftToken, planIdsJson, containersJson, List.of()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java new file mode 100644 index 000000000..b4627ab55 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.dispatch.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class DispatchRequests { + private DispatchRequests() { + } + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo, + String vehiclePlate, String status, String fbaAppointmentNo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds, + @Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, + @Size(max = 32) String fbaAppointmentStatus, + @Size(max = 32) String vehicleAppointmentStatus, + @Size(max = 64) String draftToken) { + public PreArrange(List planPalletIds, String route, String fbaAppointmentNo, + String fbaAppointmentStatus, String vehicleAppointmentStatus) { + this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null); + } + } + + public record DraftPallet(@NotNull Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId) { + public DraftPallet(Long planPalletId, String draftToken) { this(planPalletId, draftToken, null); } + } + + public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull @Min(1) @Max(500) Integer capacity) {} + + public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) { + } + + public record DraftSnapshot(@NotBlank @Size(max = 64) String draftToken, + @NotEmpty @Size(max = 500) List<@NotNull Long> planIds, + @NotBlank @Size(max = 20000) String containersJson) { + } + + public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName, + @Size(max = 64) String driverPhone, @Size(max = 255) String carrierName, + @Size(max = 255) String route, LocalDateTime departAt, + @Size(max = 64) String fbaAppointmentNo, + @Size(max = 32) String fbaAppointmentStatus, + @Size(max = 32) String vehicleAppointmentStatus, + LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java new file mode 100644 index 000000000..5e7d2a81d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName, + String driverPhone, String carrierName, String orderNos, String route, + String overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt, + String fbaAppointmentNo, String fbaAppointmentStatus, + String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status, + int itemCount, int palletCount, int packageCount, int pickedPackageCount, + int shippedPackageCount, int remainingPackageCount, String partialCompletionReason, + LocalDateTime createdAt, + List items) { + public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo, + String overseasWarehouseId, String overseasWarehouseName, + String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, int packageCount, + BigDecimal volumeCbm, String sourceStage) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java new file mode 100644 index 000000000..bf4ce5818 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.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; + +@Data +@TableName("ll_dispatch_draft") +public class DispatchDraft { + @TableId(type = IdType.INPUT) + private Long id; + private String draftToken; + private String customerId; + private String operatorId; + private String planIdsJson; + private String containersJson; + private String status; + private LocalDateTime expiresAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java new file mode 100644 index 000000000..74599d6bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.dispatch.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; + +@Data +@TableName("ll_dispatch_draft_container") +public class DispatchDraftContainer { + @TableId(type = IdType.INPUT) + private Long id; + private Long draftId; + private String draftToken; + private Integer sequenceNo; + private Integer capacity; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java new file mode 100644 index 000000000..2fa8e73d0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.dispatch.entity; + +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; + +/** Immutable pallet/cargo snapshot selected into a dispatch order. */ +@Data +@TableName("ll_dispatch_item") +public class DispatchItem { + @TableId(type = IdType.INPUT) + private Long id; + private Long dispatchOrderId; + private String customerId; + private Long planId; + private Long planPalletId; + private Long warehousePalletId; + private Long orderId; + private String overseasWarehouseId; + 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; + /** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */ + private String sourceStage; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java new file mode 100644 index 000000000..78caeb77d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.dispatch.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; + +/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */ +@Data +@TableName("ll_dispatch_order") +public class DispatchOrder { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String dispatchNo; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String vehiclePlate; + private String driverName; + private String driverPhone; + private String carrierName; + private String route; + private LocalDateTime departAt; + private String fbaAppointmentNo; + private String fbaAppointmentStatus; + private String vehicleAppointmentStatus; + private LocalDateTime estimatedArrivalAt; + private String status; + private Integer itemCount; + private Integer palletCount; + private Integer packageCount; + private Integer pickedPackageCount; + private Integer shippedPackageCount; + private String partialCompletionReason; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java new file mode 100644 index 000000000..a62da4a4d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** Prevents a source pallet from being added to more than one active dispatch order. */ +@Data +@TableName("ll_dispatch_pallet_lock") +public class DispatchPalletLock { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String sourcePalletKey; + /** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String activeKey; + private Long planId; + private Long planPalletId; + private Long warehousePalletId; + private Long dispatchOrderId; + private Long draftContainerId; + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String draftToken; + private String operatorId; + private String status; + private LocalDateTime lockedAt; + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private LocalDateTime expiresAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java new file mode 100644 index 000000000..e5e4f5875 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -0,0 +1,126 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +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 DispatchCandidateMapper { + @Select("") + List findCustomerScopesByPlanIds(@Param("planIds") List planIds); + + @Select("") + List findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List planPalletIds); + + @Select("") + List available(@Param("customerId") String customerId, + @Param("planIds") List planIds, + @Param("orderNo") String orderNo, + @Param("fbaWarehouseCode") String fbaWarehouseCode, + @Param("sourceStage") String sourceStage, + @Param("draftToken") String draftToken); + + @Select("") + List lockSelected(@Param("customerId") String customerId, + @Param("planPalletIds") List planPalletIds); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java new file mode 100644 index 000000000..43b428aaf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; +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 DispatchDraftContainerMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_draft_container WHERE draft_token=#{draftToken} ORDER BY sequence_no,id") + List findByToken(@Param("draftToken") String draftToken); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java new file mode 100644 index 000000000..17321a547 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchDraft; +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 DispatchDraftMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_draft WHERE operator_id=#{operatorId} AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY updated_at DESC") + List findActiveByOperator(@Param("operatorId") String operatorId); + + @Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' LIMIT 1 FOR UPDATE") + DispatchDraft lockActive(@Param("draftToken") String draftToken, @Param("operatorId") String operatorId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java new file mode 100644 index 000000000..2dff34801 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface DispatchItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id") + List findByDispatchOrder(@Param("customerId") String customerId, + @Param("dispatchOrderId") Long dispatchOrderId); + + @Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " + + "JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=i.customer_id " + + "SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " + + "WHERE i.customer_id=#{customerId} AND i.dispatch_order_id=#{dispatchOrderId} " + + "AND i.warehouse_pallet_id IS NULL AND p.warehouse_pallet_id IS NOT NULL") + int bindReceivedPallets(@Param("customerId") String customerId, + @Param("dispatchOrderId") Long dispatchOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java new file mode 100644 index 000000000..3dd3fe6e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +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 DispatchOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1") + DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo); + + @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} ORDER BY customer_id LIMIT 2") + List findAcrossCustomersByDispatchNo(@Param("dispatchNo") String dispatchNo); + + @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") + DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java new file mode 100644 index 000000000..38ed3c93d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface DispatchPalletLockMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " + + "AND status='ACTIVE' LIMIT 1 FOR UPDATE") + DispatchPalletLock lockActive(@Param("customerId") String customerId, + @Param("sourcePalletKey") String sourcePalletKey); + + @Update("UPDATE ll_dispatch_pallet_lock SET active_key=NULL,updated_at=CURRENT_TIMESTAMP(3) " + + "WHERE customer_id=#{customerId} AND active_key=#{activeKey} AND status!='ACTIVE'") + int clearInactiveActiveKey(@Param("customerId") String customerId, + @Param("activeKey") String activeKey); +} 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 new file mode 100644 index 000000000..b5779de7d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -0,0 +1,59 @@ +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, + String containerNo, + LocalDateTime appointmentAt, + LocalDateTime expectedLoadingAt, + String overseasWarehouseId, + 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 +) { + public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, + String overseasWarehouseId, String overseasWarehouseName, String orderNo, + String orderRefs, String inboundCode, String palletNo, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + String fbaWarehouseCode, Integer packageCount, BigDecimal volumeCbm, + String sourceStage) { + this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId, + overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId, + destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm, + sourceStage, null); + } + + public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, + String orderNo, String orderRefs, String inboundCode, String palletNo, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount, + BigDecimal volumeCbm, String sourceStage) { + this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode, + palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, + fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null); + } + public String sourcePalletKey() { + // A plan pallet keeps the same lock identity before and after the physical pallet is received. + return planPalletId != null ? "PLAN:" + planPalletId : "RECEIVED:" + warehousePalletId; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java new file mode 100644 index 000000000..ddfbe6b50 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dispatch.pda; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.dispatch.pda.mapper") +public class DispatchPdaMapperConfiguration {} 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 new file mode 100644 index 000000000..77005a1e8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.dispatch.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +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 jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") +@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}") +public class DispatchPdaController { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + @Resource private DispatchPdaWorkflowService service; + + @PostMapping("/push") + public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DispatchPdaRequests.Push request) { + requireOperate(user); + return ApiResponse.success(service.push(scope(user, customerId), dispatchNo, request)); + } + @GetMapping + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.task(scope(user, customerId), dispatchNo)); + } + @PostMapping("/items/{itemId}/pick") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, @PathVariable long itemId, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DispatchPdaRequests.Pick request) { + requireOperate(user); + return ApiResponse.success(service.pick(scope(user, customerId), dispatchNo, itemId, operator(user), request)); + } + @PostMapping("/complete") + public ApiResponse completePicking( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DispatchPdaRequests.CompletePicking request) { + requireOperate(user); + return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request)); + } + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedCustomer.trim(); + } + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } + private void requireOperate(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java new file mode 100644 index 000000000..e98c0cdcc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.dispatch.pda.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class DispatchPdaRequests { + private DispatchPdaRequests() {} + public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName, + String warehouseId) { + public Push(String idempotencyKey, String operatorId, String operatorName) { + this(idempotencyKey, operatorId, operatorName, null); + } + } + public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, + @NotBlank String idempotencyKey, String remark, String warehouseId) { + public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) { + this(positionId, packages, productQuantity, idempotencyKey, remark, null); + } + } + public record Depart(@NotBlank String idempotencyKey, String remark) {} + public record CompletePicking(@NotBlank String idempotencyKey, + @NotBlank @Size(max = 1000) String reason) {} +} 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 new file mode 100644 index 000000000..638d51be2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dispatch.pda.dto; + +import java.util.List; + +public final class DispatchPdaResponses { + private DispatchPdaResponses() {} + public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status, + List items) {} + public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages, + long pickedPackages, List positions) {} + public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages, + long pickedPackages) {} + public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages, + boolean palletScrapped, boolean replayed) {} + public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) { + public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); } + } + public record PickingCompleted(String dispatchNo, String status, long plannedPackages, + long pickedPackages, long remainingPackages, boolean partial, + boolean replayed) { + public PickingCompleted withReplay() { + return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages, + remainingPackages, partial, true); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java new file mode 100644 index 000000000..94637d9a8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.pda.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; + +@Data +@TableName("ll_dispatch_pda_operation") +public class DispatchPdaOperation { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long taskId; + private String operationType; + private String idempotencyKey; + private String requestHash; + private String resultJson; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java new file mode 100644 index 000000000..fe47e73c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.pda.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; + +@Data +@TableName("ll_dispatch_pda_pick") +public class DispatchPdaPick { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long taskId; + private Long dispatchItemId; + private Long inventoryPositionId; + private Long packages; + private Long productQuantity; + private String idempotencyKey; + private LocalDateTime pickedAt; + private LocalDateTime createdAt; +} 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 new file mode 100644 index 000000000..4f87b9f53 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.pda.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 FIFO attribution of a PDA pick to its inbound receipt and tail-leg order. */ +@Data +@TableName("ll_dispatch_pda_pick_order") +public class DispatchPdaPickOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long pickId; + private Long receiptId; + private Long orderId; + private Long packages; + private Long productQuantity; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java new file mode 100644 index 000000000..81ccee623 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.pda.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; + +@Data +@TableName("ll_dispatch_pda_task") +public class DispatchPdaTask { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long dispatchOrderId; + private String taskNo; + private String status; + private String operatorId; + private String operatorName; + private String warehouseId; + private LocalDateTime pushedAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java new file mode 100644 index 000000000..a94613689 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface DispatchPdaOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + DispatchPdaOperation findKey(@Param("customerId") String customerId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java new file mode 100644 index 000000000..2b8c0f33c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface DispatchPdaPickMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} ORDER BY id") + List listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId); + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}") + long totalItem(@Param("customerId") String customerId, @Param("taskId") Long taskId, @Param("itemId") Long itemId); +} 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 new file mode 100644 index 000000000..c14b0f8a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +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 DispatchPdaPickOrderMapper extends BaseMapper { + @Select("SELECT r.* FROM ll_inbound_stock_receipt r " + + "WHERE r.customer_id=#{customerId} AND r.inventory_position_id=#{positionId} " + + "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.receipt_id=r.id) ORDER BY r.received_at,r.id") + List availableReceipts(@Param("customerId") String customerId, + @Param("positionId") Long positionId); + + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order " + + "WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}") + long attributedPackages(@Param("customerId") String customerId, @Param("receiptId") Long receiptId); + + @Select("SELECT COALESCE(SUM(product_quantity),0) FROM ll_dispatch_pda_pick_order " + + "WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}") + long attributedQuantity(@Param("customerId") String customerId, @Param("receiptId") Long receiptId); + + @Select("SELECT * FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customerId} AND pick_id=#{pickId} ORDER BY id") + List listPick(@Param("customerId") String customerId, @Param("pickId") Long pickId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java new file mode 100644 index 000000000..6c548691e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface DispatchPdaTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") + DispatchPdaTask lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); + + @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1") + DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); +} 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 new file mode 100644 index 000000000..96da4a278 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -0,0 +1,277 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.entity.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class DispatchPdaWorkflowService { + @Resource private DispatchOrderMapper orders; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private DispatchItemMapper items; + @Resource private DispatchPdaTaskMapper tasks; + @Resource private DispatchPdaPickMapper picks; + @Resource private DispatchPdaOperationMapper operations; + @Resource private DispatchPalletLockMapper palletLocks; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryCommandService inventory; + @Resource private DispatchPickOrderAttributionService pickAttribution; + @Resource private ObjectMapper objectMapper; + + @Transactional + public DispatchPdaResponses.Task push(String customerId, String dispatchNo, DispatchPdaRequests.Push request) { + DispatchOrder order = requireOrder(customerId, dispatchNo); + requirePdaStatus(order); + requireVehicle(order); + String warehouseId = warehouse(request.warehouseId()); + requireDispatchWarehouse(order, warehouseId); + DispatchPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Task.class); + items.bindReceivedPallets(customerId, order.getId()); + List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); + if (dispatchItems.isEmpty()) throw conflict("Dispatch has no pallets to push"); + if (dispatchItems.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { + throw conflict("All pre-arranged pallets must be received before PDA push"); + } + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + LocalDateTime now = LocalDateTime.now(); + if (task == null) { + task = new DispatchPdaTask(); + task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(order.getId()); + task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); + task.setWarehouseId(warehouseId); + task.setOperatorId(trim(request.operatorId())); task.setOperatorName(trim(request.operatorName())); + task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); tasks.insert(task); + } + if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); } + if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA task warehouse does not match selected warehouse"); + DispatchPdaResponses.Task result = task(customerId, order, task, dispatchItems); + save(customerId, task.getId(), "PUSH", request.idempotencyKey(), request, result); + return result; + } + + @Transactional(readOnly = true) + public DispatchPdaResponses.Task task(String customerId, String dispatchNo) { + DispatchOrder order = requireOrder(customerId, dispatchNo); + DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId()); + if (task == null) throw notFound("Dispatch PDA task not found"); + return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId())); + } + + @Transactional + public DispatchPdaResponses.Picked pick(String customerId, String dispatchNo, long itemId, + String operatorId, DispatchPdaRequests.Pick request) { + DispatchOrder order = requireOrder(customerId, dispatchNo); + requirePdaStatus(order); + requireVehicle(order); + String warehouseId = warehouse(request.warehouseId()); + requireDispatchWarehouse(order, warehouseId); + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { + 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"); + DispatchItem item = items.selectById(itemId); + if (item == null || !customerId.equals(item.getCustomerId()) || !order.getId().equals(item.getDispatchOrderId())) { + throw notFound("Dispatch pallet item not found"); + } + if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse"); + DispatchPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Picked.class); + InventoryPosition position = positions.selectById(request.positionId()); + if (position == null || !customerId.equals(position.getCustomerId()) + || item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) { + throw conflict("Inventory position does not belong to this dispatch pallet"); + } + long picked = picks.totalItem(customerId, task.getId(), itemId); + if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); + String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); + InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(), + request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, + keyBase + ":ALLOCATE", request.remark()); + inventory.allocate(customerId, operatorId, move); + inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), + request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, + keyBase + ":PICK", request.remark())); + DispatchPdaPick pick = new DispatchPdaPick(); + pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); + pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages()); + pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); + pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); + pickAttribution.attribute(customerId, pick); + long total = picked + request.packages(); + order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages())); + task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now()); + if (allPicked(customerId, task.getId(), items.findByDispatchOrder(customerId, order.getId()))) { + task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART"); + } + order.setUpdatedAt(LocalDateTime.now()); + orders.updateById(order); + tasks.updateById(task); + DispatchPdaResponses.Picked result = new DispatchPdaResponses.Picked(itemId, request.positionId(), request.packages(), total, false, false); + save(customerId, task.getId(), "PICK", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public DispatchPdaResponses.PickingCompleted completePicking(String customerId, String dispatchNo, + DispatchPdaRequests.CompletePicking request) { + DispatchOrder found = requireOrder(customerId, dispatchNo); + DispatchOrder order = orders.lockById(customerId, found.getId()); + if (order == null) throw notFound("Dispatch order not found"); + DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay(); + requireVehicle(order); + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { + throw conflict("Dispatch PDA task is not available for completion"); + } + List source = items.findByDispatchOrder(customerId, order.getId()); + long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) + .mapToLong(Integer::longValue).sum(); + long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum(); + if (picked <= 0 || picked > planned) throw conflict("PDA completion requires a valid picked package count"); + boolean partial = picked < planned; + LocalDateTime now = LocalDateTime.now(); + task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task); + order.setStatus(partial ? "PARTIAL_READY_TO_DEPART" : "READY_TO_DEPART"); + order.setPartialCompletionReason(partial ? request.reason().trim() : null); + order.setPickedPackageCount((int) picked); + order.setUpdatedAt(now); orders.updateById(order); + DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), + order.getStatus(), planned, picked, planned - picked, partial, false); + save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result); + return result; + } + + /** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */ + @Transactional + public DispatchPdaResponses.Departed depart(String customerId, String dispatchNo, String operatorId, + DispatchPdaRequests.Depart request) { + DispatchOrder found = requireOrder(customerId, dispatchNo); + 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(); + requireVehicle(order); + OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId()); + if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) { + throw conflict("Dispatch must be transferred to a pending outbound order before departure"); + } + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + if (task == null || !"COMPLETED".equals(task.getStatus()) + || !("READY_TO_DEPART".equals(order.getStatus()) || "PARTIAL_READY_TO_DEPART".equals(order.getStatus()))) { + throw conflict("Dispatch must complete PDA picking before departure"); + } + + List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); + boolean partial = "PARTIAL_READY_TO_DEPART".equals(order.getStatus()); + if (!partial && !allPicked(customerId, task.getId(), dispatchItems)) { + throw conflict("Every dispatch pallet must be fully picked before departure"); + } + List dispatchPicks = picks.listTask(customerId, task.getId()); + if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship"); + long shipped = 0; + for (DispatchPdaPick pick : dispatchPicks) { + String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP"; + inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move( + pick.getInventoryPositionId(), n(pick.getPackages()), + outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark())); + shipped += n(pick.getPackages()); + } + LocalDateTime now = LocalDateTime.now(); + pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); + outbound.setStatus("OUTBOUNDED"); + outbound.setUpdatedAt(now); + outboundOrders.updateById(outbound); + order.setShippedPackageCount((int) shipped); + order.setStatus(partial ? "PARTIAL_DEPARTED" : "DEPARTED"); + order.setUpdatedAt(now); + orders.updateById(order); + releasePalletLocks(customerId, order.getId(), now); + DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(), + order.getStatus(), shipped, false); + save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result); + return result; + } + + private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List source) { + List values = new ArrayList<>(); + for (DispatchItem item : source) { + List palletPositions = item.getWarehousePalletId() == null ? List.of() + : positions.listPallet(customerId, item.getWarehousePalletId()); + values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), + item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(), + picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p -> + new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()), + n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList())); + } + return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), task.getWarehouseId(), task.getStatus(), values); + } + + private boolean allPicked(String customerId, Long taskId, List source) { + return source.stream().allMatch(item -> picks.totalItem(customerId, taskId, item.getId()) == item.getPackageCount()); + } + private void releasePalletLocks(String customerId, Long dispatchOrderId, LocalDateTime now) { + List activeLocks = palletLocks.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(DispatchPalletLock::getCustomerId, customerId) + .eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + for (DispatchPalletLock lock : activeLocks) { + lock.setStatus("RELEASED"); + lock.setActiveKey(null); + lock.setReleasedAt(now); + lock.setUpdatedAt(now); + palletLocks.updateById(lock); + } + } + private DispatchOrder requireOrder(String customerId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(customerId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; } + private void requirePdaStatus(DispatchOrder order) { + if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus()) + || "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA"); + } + private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); } + private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); } + private static void requireDispatchWarehouse(DispatchOrder order, String warehouseId) { + if (trim(order.getOverseasWarehouseId()) == null || !warehouseId.equals(order.getOverseasWarehouseId().trim())) { + throw conflict("Selected warehouse does not match dispatch warehouse"); + } + } + private DispatchPdaOperation replay(String customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; } + private void save(String customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); } + private T read(DispatchPdaOperation op, Class type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } } + private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } } + private static long n(Long value) { return value == null ? 0 : value; } + private static long n(Integer value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); } + private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} 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 new file mode 100644 index 000000000..441d30d56 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -0,0 +1,105 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class DispatchPickOrderAttributionService { + @Resource private DispatchPdaPickOrderMapper attributions; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderStatusHistoryMapper history; + + /** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */ + public List attribute(String customerId, DispatchPdaPick pick) { + 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())) { + if (remainingPackages == 0) break; + long receiptPackages = value(receipt.getReceivedPackages()) + - attributions.attributedPackages(customerId, receipt.getId()); + if (receiptPackages <= 0) continue; + long packages = Math.min(remainingPackages, receiptPackages); + long nextPackages = allocatedPackages + packages; + long nextQuantity = totalPackages == 0 ? 0 : Math.floorDiv(totalQuantity * nextPackages, totalPackages); + long quantity = nextQuantity - allocatedQuantity; + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setId(IdWorker.getId()); + row.setCustomerId(customerId); + row.setPickId(pick.getId()); + row.setReceiptId(receipt.getId()); + row.setOrderId(receipt.getOrderId()); + row.setPackages(packages); + row.setProductQuantity(quantity); + row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt()); + attributions.insert(row); + pickedByOrder.merge(receipt.getOrderId(), packages, Long::sum); + remainingPackages -= packages; + allocatedPackages = nextPackages; + allocatedQuantity = nextQuantity; + } + if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders"); + 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"); + } + } + return pickedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList(); + } + + /** 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<>(); + for (DispatchPdaPick pick : picks) { + for (DispatchPdaPickOrder row : attributions.listPick(customerId, pick.getId())) { + shippedByOrder.merge(row.getOrderId(), value(row.getPackages()), Long::sum); + } + } + for (Map.Entry entry : shippedByOrder.entrySet()) { + LastlegOrder before = orders.lockById(customerId, entry.getKey()); + if (before == null || orders.transferPickedToShipped(customerId, entry.getKey(), entry.getValue(), outboundNo) != 1) { + throw conflict("Tail-leg order picked packages are inconsistent"); + } + long shipped = value(before.getShippedPackageCount()) + entry.getValue(); + String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED"; + LastlegOrderStatusHistory row = new LastlegOrderStatusHistory(); + row.setOrderId(entry.getKey()); + row.setFromStatus(before.getCurrentStatus()); + row.setToStatus(to); + row.setEventId("DISPATCH_DEPART:" + dispatchNo); + row.setRemark("Dispatch departed " + entry.getValue() + " packages; cumulative shipped " + + shipped + "/" + value(before.getPackageCount())); + row.setCreatedAt(departedAt); + history.insert(row); + } + return shippedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList(); + } + + public record OrderPackages(Long orderId, long packages) {} + private static long value(Long value) { return value == null ? 0 : value; } + private static long value(Integer value) { return value == null ? 0 : value; } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} 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 new file mode 100644 index 000000000..54a7aa03e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -0,0 +1,516 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.entity.DispatchDraft; +import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper; +import com.baiyue.lastleg.dispatch.dto.DispatchContainerResponse; +import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class DispatchService { + public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE"; + public static final String CANCELLED = "CANCELLED"; + + @Resource private DispatchOrderMapper orders; + @Resource private DispatchItemMapper items; + @Resource private DispatchPalletLockMapper locks; + @Resource private DispatchDraftMapper drafts; + @Resource private DispatchDraftContainerMapper draftContainers; + @Resource private DispatchCandidateMapper candidates; + + public String resolveCustomerScopeForPlans(List planIds) { + return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), + "Selected inbound plans"); + } + + public String resolveCustomerScopeForPallets(List planPalletIds) { + return requireSingleCustomerScope( + candidates.findCustomerScopesByPlanPalletIds(normalizeIds(planPalletIds, "Plan pallet IDs")), + "Selected pallets"); + } + + public List candidates(String customerId, List planIds, String orderNo, + String fbaWarehouseCode, String sourceStage, String draftToken) { + validateSourceStage(sourceStage); + if (!StringUtils.hasText(customerId)) return List.of(); + List scopedPlanIds = normalizeIds(planIds, "Plan IDs"); + return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode), + trimToNull(sourceStage), trimToNull(draftToken)); + } + public List candidates(String customerId, List planIds, String orderNo, + String fbaWarehouseCode, String sourceStage) { + return candidates(customerId, planIds, orderNo, fbaWarehouseCode, sourceStage, null); + } + + public List platformCandidates(String orderNo, String fbaWarehouseCode, + String sourceStage, String draftToken) { + validateSourceStage(sourceStage); + return candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), + trimToNull(sourceStage), trimToNull(draftToken)); + } + public List platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage) { + return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null); + } + + public List activeDrafts(String operatorId) { + return drafts.findActiveByOperator(trimToNull(operatorId)).stream() + .map(item -> new DispatchDraftResponse(item.getDraftToken(), item.getPlanIdsJson(), item.getContainersJson(), containers(item.getDraftToken()))).toList(); + } + + private List containers(String token) { + return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), + locks.selectList(new LambdaQueryWrapper().eq(DispatchPalletLock::getDraftContainerId, c.getId()).eq(DispatchPalletLock::getStatus, "ACTIVE")).stream().map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList(); + } + + @Transactional + public DispatchContainerResponse createDraftContainer(String operatorId, DispatchRequests.DraftContainer request) { + DispatchDraft draft = ensureDraft(operatorId, request.draftToken()); + int next = draftContainers.findByToken(request.draftToken()).stream().mapToInt(DispatchDraftContainer::getSequenceNo).max().orElse(0) + 1; + DispatchDraftContainer c = new DispatchDraftContainer(); c.setId(IdWorker.getId()); c.setDraftId(draft.getId()); c.setDraftToken(request.draftToken()); c.setSequenceNo(next); c.setCapacity(request.capacity()); c.setCreatedAt(LocalDateTime.now()); c.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(c); + return new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), List.of()); + } + + private DispatchDraft ensureDraft(String operatorId, String token) { + DispatchDraft draft = drafts.lockActive(token, trimToNull(operatorId)); + if (draft != null) return draft; + LocalDateTime now = LocalDateTime.now(); draft = new DispatchDraft(); draft.setId(IdWorker.getId()); draft.setDraftToken(token); draft.setOperatorId(trimToNull(operatorId)); draft.setStatus("ACTIVE"); draft.setCreatedAt(now); draft.setUpdatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); drafts.insert(draft); return draft; + } + + @Transactional + public void saveDraftSnapshot(String operatorId, DispatchRequests.DraftSnapshot request) { + String customerId = resolveCustomerScopeForPlans(request.planIds()); + LocalDateTime now = LocalDateTime.now(); + DispatchDraft draft = drafts.lockActive(request.draftToken(), trimToNull(operatorId)); + if (draft == null) { + draft = new DispatchDraft(); draft.setId(IdWorker.getId()); draft.setDraftToken(request.draftToken()); + draft.setOperatorId(trimToNull(operatorId)); draft.setCustomerId(customerId); draft.setStatus("ACTIVE"); + draft.setCreatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); + } + draft.setPlanIdsJson(request.planIds().stream().map(String::valueOf).collect(Collectors.joining(","))); + draft.setContainersJson(request.containersJson()); draft.setUpdatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); + drafts.insertOrUpdate(draft); + } + + @Transactional + public void lockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) { + DispatchDraft draft = ensureDraft(operatorId, request.draftToken()); + DispatchDraftContainer boundContainer = request.draftContainerId() == null ? null : draftContainers.selectById(request.draftContainerId()); + if (boundContainer == null && request.draftContainerId() == null) { + 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()); + LocalDateTime now = LocalDateTime.now(); + DispatchPalletLock active = activeLock(customerId, candidate); + if (active != null && active.getExpiresAt() != null && !active.getExpiresAt().isAfter(now)) { + releaseLock(active, now); + active = null; + } + if (active != null) { + if (request.draftToken().equals(active.getDraftToken()) + && Objects.equals(trimToNull(operatorId), active.getOperatorId())) return; + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Pallet is already locked by another dispatch draft: " + candidate.palletNo()); + } + // Older releases did not persist active_key=NULL; clear that inactive unique-key residue before relocking. + locks.clearInactiveActiveKey(customerId, candidate.sourcePalletKey()); + DispatchPalletLock lock = new DispatchPalletLock(); + lock.setId(IdWorker.getId()); + lock.setCustomerId(customerId); + lock.setSourcePalletKey(candidate.sourcePalletKey()); + lock.setActiveKey(candidate.sourcePalletKey()); + lock.setPlanId(candidate.planId()); + lock.setPlanPalletId(candidate.planPalletId()); + lock.setWarehousePalletId(candidate.warehousePalletId()); + lock.setDraftToken(request.draftToken()); + lock.setDraftContainerId(request.draftContainerId()); + lock.setOperatorId(trimToNull(operatorId)); + lock.setStatus("ACTIVE"); + lock.setLockedAt(now); + lock.setExpiresAt(now.plusMinutes(30)); + lock.setCreatedAt(now); + lock.setUpdatedAt(now); + try { + locks.insert(lock); + } catch (DuplicateKeyException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Pallet is already locked by another dispatch draft: " + candidate.palletNo(), exception); + } + } + + @Transactional + public void unlockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) { + DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId()); + DispatchPalletLock active = activeLock(customerId, candidate); + if (active == null) return; + requireDraftOwner(active, operatorId, request.draftToken()); + releaseLock(active, LocalDateTime.now()); + } + + @Transactional + public void releaseDraft(String operatorId, String draftToken) { + List activeLocks = locks.selectList(new LambdaQueryWrapper() + .eq(DispatchPalletLock::getDraftToken, draftToken) + .eq(DispatchPalletLock::getOperatorId, trimToNull(operatorId)) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + LocalDateTime now = LocalDateTime.now(); + activeLocks.forEach(lock -> releaseLock(lock, now)); + draftContainers.delete(new LambdaQueryWrapper().eq(DispatchDraftContainer::getDraftToken, draftToken)); + DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId)); + if (draft != null) { draft.setStatus("RELEASED"); draft.setReleasedAt(now); draft.setUpdatedAt(now); drafts.updateById(draft); } + } + + @Transactional + public void deleteDraftContainer(String operatorId, String token, Long containerId) { + if (drafts.lockActive(token, trimToNull(operatorId)) == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿不存在或已失效"); + DispatchDraftContainer c = draftContainers.selectById(containerId); + if (c == null || !token.equals(c.getDraftToken())) return; + List bound = locks.selectList(new LambdaQueryWrapper().eq(DispatchPalletLock::getDraftContainerId, containerId).eq(DispatchPalletLock::getStatus, "ACTIVE")); + bound.forEach(lock -> { requireDraftOwner(lock, operatorId, token); releaseLock(lock, LocalDateTime.now()); }); + draftContainers.deleteById(containerId); + } + + @Transactional + public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { + if (!StringUtils.hasText(customerId)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "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()) { + 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()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); + } + Set warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) + .filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet()); + if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse"); + } + String draftToken = trimToNull(request.draftToken()); + for (DispatchPalletCandidate candidate : selected) { + DispatchPalletLock active = activeLock(customerId, candidate); + if (draftToken != null) { + if (active == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Pallet draft lock has expired: " + candidate.palletNo()); + } + requireDraftOwner(active, operatorId, draftToken); + } else if (active != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Pallet is already locked by another active dispatch: " + candidate.palletNo()); + } + } + + LocalDateTime now = LocalDateTime.now(); + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(IdWorker.getId()); + dispatch.setCustomerId(customerId); + dispatch.setDispatchNo("PC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + dispatch.getId()); + dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId()); + dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); + dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(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) { + DispatchPalletLock lock = activeLock(customerId, candidate); + if (draftToken != null) { + requireDraftOwner(lock, operatorId, draftToken); + lock.setDispatchOrderId(dispatch.getId()); + lock.setDraftToken(null); + lock.setExpiresAt(null); + lock.setUpdatedAt(now); + locks.updateById(lock); + } else { + lock = new DispatchPalletLock(); + lock.setId(IdWorker.getId()); + lock.setCustomerId(customerId); + lock.setSourcePalletKey(candidate.sourcePalletKey()); + lock.setActiveKey(candidate.sourcePalletKey()); + lock.setPlanId(candidate.planId()); + lock.setPlanPalletId(candidate.planPalletId()); + lock.setWarehousePalletId(candidate.warehousePalletId()); + lock.setDispatchOrderId(dispatch.getId()); + lock.setOperatorId(trimToNull(operatorId)); + lock.setStatus("ACTIVE"); + lock.setLockedAt(now); + lock.setCreatedAt(now); + lock.setUpdatedAt(now); + try { + locks.insert(lock); + } catch (DuplicateKeyException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception); + } + } + DispatchItem item = new DispatchItem(); + item.setId(IdWorker.getId()); + item.setDispatchOrderId(dispatch.getId()); + item.setCustomerId(customerId); + item.setPlanId(candidate.planId()); + item.setPlanPalletId(candidate.planPalletId()); + 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.setDestinationWarehouseId(candidate.destinationWarehouseId()); + item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); + item.setDestinationWarehouseName(candidate.destinationWarehouseName()); + item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); + item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); + item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); + item.setSourceStage(candidate.sourceStage()); + item.setCreatedAt(now); + items.insert(item); + } + return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + } + + public PageResult page(String customerId, DispatchRequests.Query query) { + if (!StringUtils.hasText(customerId)) { + return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + } + return page(customerId, query, false); + } + + public PageResult platformPage(DispatchRequests.Query query) { + return page(null, query, true); + } + + private PageResult page(String customerId, DispatchRequests.Query query, + boolean acrossCustomers) { + Page page = orders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper() + .eq(!acrossCustomers, DispatchOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo()) + .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) + .eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) + .like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo, + query.fbaAppointmentNo()) + .orderByDesc(DispatchOrder::getCreatedAt)); + List records = page.getRecords().stream() + .map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()))).toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public DispatchResponse detail(String customerId, String dispatchNo) { + DispatchOrder dispatch = find(customerId, dispatchNo); + return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); + } + + public DispatchResponse platformDetail(String dispatchNo) { + List matches = orders.findAcrossCustomersByDispatchNo(dispatchNo); + if (matches == null || matches.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + } + if (matches.size() > 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Dispatch number is not unique across customers"); + } + DispatchOrder dispatch = matches.get(0); + return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); + } + + @Transactional + public DispatchResponse update(String customerId, String dispatchNo, DispatchRequests.Update request) { + DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId()); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + // Vehicle plate is intentionally editable through all dispatch states. + dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); + dispatch.setDriverName(trimToNull(request.driverName())); + dispatch.setDriverPhone(trimToNull(request.driverPhone())); + dispatch.setCarrierName(trimToNull(request.carrierName())); + dispatch.setRoute(trimToNull(request.route())); + dispatch.setDepartAt(request.departAt()); + dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); + dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); + dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); + dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); + if (StringUtils.hasText(request.status())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Dispatch status is PDA-managed and cannot be changed by the edit endpoint"); + } + dispatch.setUpdatedAt(LocalDateTime.now()); + orders.updateById(dispatch); + return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + } + + @Transactional + public void cancel(String customerId, String dispatchNo) { + DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId()); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Dispatched order cannot be cancelled"); + } + LocalDateTime now = LocalDateTime.now(); + dispatch.setStatus(CANCELLED); + dispatch.setUpdatedAt(now); + orders.updateById(dispatch); + List activeLocks = locks.selectList(new LambdaQueryWrapper() + .eq(DispatchPalletLock::getCustomerId, customerId) + .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + for (DispatchPalletLock lock : activeLocks) { + lock.setStatus("RELEASED"); + lock.setActiveKey(null); + lock.setReleasedAt(now); + lock.setUpdatedAt(now); + locks.updateById(lock); + } + } + + private DispatchOrder find(String customerId, String dispatchNo) { + if (!StringUtils.hasText(customerId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + } + DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + return dispatch; + } + + private DispatchResponse response(DispatchOrder order, List itemList) { + String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream()) + .distinct().collect(Collectors.joining(",")); + return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(), + order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(), + order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(), + order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(), + order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0), + Objects.requireNonNullElse(order.getPalletCount(), 0), Objects.requireNonNullElse(order.getPackageCount(), 0), + Objects.requireNonNullElse(order.getPickedPackageCount(), 0), + Objects.requireNonNullElse(order.getShippedPackageCount(), 0), + Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0) + - Objects.requireNonNullElse(order.getShippedPackageCount(), 0)), order.getPartialCompletionReason(), + order.getCreatedAt(), itemList.stream().map(item -> new DispatchResponse.Item(item.getId(), item.getPlanPalletId(), + item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(), + item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(), + item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), + Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList()); + } + + private static String route(List selected) { + return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText) + .distinct().collect(Collectors.joining(" -> ")); + } + + private DispatchPalletCandidate requireDraftCandidate(String customerId, Long planPalletId) { + List found = candidates.lockSelected(customerId, List.of(planPalletId)); + if (found.size() != 1) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Pallet is not available for dispatch"); + } + return found.get(0); + } + + private DispatchPalletLock activeLock(String customerId, DispatchPalletCandidate candidate) { + DispatchPalletLock active = locks.lockActive(customerId, candidate.sourcePalletKey()); + if (active == null && candidate.warehousePalletId() != null) { + active = locks.lockActive(customerId, "RECEIVED:" + candidate.warehousePalletId()); + } + return active; + } + + private void requireDraftOwner(DispatchPalletLock lock, String operatorId, String draftToken) { + if (lock == null || !draftToken.equals(lock.getDraftToken()) + || !Objects.equals(trimToNull(operatorId), lock.getOperatorId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet is locked by another dispatch draft"); + } + } + + private void releaseLock(DispatchPalletLock lock, LocalDateTime now) { + lock.setStatus("RELEASED"); + lock.setActiveKey(null); + lock.setReleasedAt(now); + lock.setUpdatedAt(now); + locks.updateById(lock); + } + + private static List splitOrderRefs(String refs, String fallback) { + String value = StringUtils.hasText(refs) ? refs : fallback; + return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of(); + } + + private static String defaultStatus(String status) { + return StringUtils.hasText(status) ? status.trim().toUpperCase() : "PENDING"; + } + + private static String trimToNull(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } + + private static void validateSourceStage(String sourceStage) { + if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED"); + } + } + + private static List normalizeIds(List ids, String label) { + if (ids == null || ids.isEmpty() || ids.stream().anyMatch(Objects::isNull)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " must be non-empty"); + } + List normalized = ids.stream().distinct().sorted().toList(); + if (normalized.size() > 500) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " cannot exceed 500 items"); + } + return normalized; + } + + private static String requireSingleCustomerScope(List customerIds, String source) { + if (customerIds == null || customerIds.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, source + " were not found"); + } + if (customerIds.size() > 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, source + " must belong to exactly one customer"); + } + return customerIds.get(0); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/DropshipMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/DropshipMapperConfiguration.java new file mode 100644 index 000000000..d1c3c3667 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/DropshipMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dropship; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.dropship.mapper") +public class DropshipMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java new file mode 100644 index 000000000..c52914792 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dropship.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/internal/v1/lastleg/dropship") +public class DropshipOrderController { + @Resource private DropshipOrderService service; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping("/receive") + public ApiResponse receive(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody DropshipRequests.Receive request) { + tokenGuard.verify(token); + return ApiResponse.success(service.receive(request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java new file mode 100644 index 000000000..1f16c8c93 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dropship.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; + +public final class DropshipRequests { + private DropshipRequests() {} + public record Receive(@NotBlank String customerId, @NotNull Long orderId, @NotNull Long warehouseId, + @NotBlank String idempotencyKey, @NotEmpty List<@Valid Pallet> pallets) {} + public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages) {} + public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount, + @Positive long productQuantity, + @NotEmpty List<@NotBlank String> cartonMarks) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java new file mode 100644 index 000000000..782f59b26 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_carton") +public class DropshipCarton { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long profileId; + private Long warehousePalletId; + private String palletNo; + private String skuCode; + private String cartonMark; + private Integer cartonSequence; + private String status; + private String outboundOrderNo; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java new file mode 100644 index 000000000..d96b4080a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_carton_ledger") +public class DropshipCartonLedger { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long cartonId; + private String cartonMark; + private String skuCode; + private String operationType; + private Long beforePackages; + private Long deltaPackages; + private Long afterPackages; + private String businessOrderNo; + private String idempotencyKey; + private String remark; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java new file mode 100644 index 000000000..15bc5046a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_order_profile") +public class DropshipOrderProfile { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long warehousePalletId; + private String palletNo; + /** Immutable pallet number snapshot supplied by the first-leg handover event. */ + private String sourcePalletNo; + private Long inventoryPositionId; + private String status; + private String idempotencyKey; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java new file mode 100644 index 000000000..ee7ba183a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_sku_inventory") +public class DropshipSkuInventory { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long profileId; + private String skuCode; + private String productName; + private Long packageCount; + private Long productQuantity; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java new file mode 100644 index 000000000..8db409367 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dropship.mapper; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +public interface DropshipCartonLedgerMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_carton_ledger WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + DropshipCartonLedger findIdempotency(@Param("customerId") String customerId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java new file mode 100644 index 000000000..6c2ad8ed2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dropship.mapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import java.util.List; +public interface DropshipCartonMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1") + DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} ORDER BY carton_sequence") + List listProfile(@Param("customerId") String customerId, @Param("profileId") Long profileId); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java new file mode 100644 index 000000000..01a7d1da2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dropship.mapper; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import java.util.List; +public interface DropshipOrderProfileMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java new file mode 100644 index 000000000..b0f1c7a5a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.dropship.mapper; + +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface DropshipSkuInventoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java new file mode 100644 index 000000000..6d37fc43f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java @@ -0,0 +1,184 @@ +package com.baiyue.lastleg.dropship.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.entity.*; +import com.baiyue.lastleg.dropship.mapper.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class DropshipOrderService { + @Resource private LastlegOrderMapper orders; + @Resource private DropshipOrderProfileMapper profiles; + @Resource private DropshipSkuInventoryMapper skuInventory; + @Resource private DropshipCartonMapper cartons; + @Resource private DropshipCartonLedgerMapper ledger; + + @Transactional + public int receive(DropshipRequests.Receive request) { + LastlegOrder order = orders.selectById(request.orderId()); + if (order == null || !request.customerId().equals(order.getCustomerId())) throw conflict("Tail order not found"); + if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) throw conflict("Order is not a one-click dropship order"); + long totalPackages = request.pallets().stream().flatMap(value -> value.skuPackages().stream()) + .mapToLong(DropshipRequests.SkuPackages::packageCount).sum(); + long totalQuantity = request.pallets().stream().flatMap(value -> value.skuPackages().stream()) + .mapToLong(DropshipRequests.SkuPackages::productQuantity).sum(); + if (order.getPackageCount() != null && order.getPackageCount().longValue() != totalPackages) { + throw conflict("SKU package total does not equal order package count"); + } + if (order.getProductQuantity() != null && order.getProductQuantity().longValue() != totalQuantity) { + throw conflict("SKU product quantity total does not equal order product quantity"); + } + List existing = profiles.listOrder(request.customerId(), request.orderId()); + if (!existing.isEmpty()) { + Set existingPallets = existing.stream().map(DropshipOrderProfile::getSourcePalletNo) + .collect(java.util.stream.Collectors.toSet()); + Set requestedPallets = request.pallets().stream().map(value -> value.palletNo().trim()) + .collect(java.util.stream.Collectors.toSet()); + if (!existingPallets.equals(requestedPallets)) throw conflict("Dropship order pallet snapshot conflicts with existing data"); + return Math.toIntExact(totalPackages); + } + + validateMarks(request); + LocalDateTime now = LocalDateTime.now(); + for (DropshipRequests.Pallet inputPallet : request.pallets()) { + DropshipOrderProfile profile = profile(request, order, inputPallet, now); + profiles.insert(profile); + int sequence = 0; + for (DropshipRequests.SkuPackages input : inputPallet.skuPackages()) { + DropshipSkuInventory inventory = new DropshipSkuInventory(); + inventory.setId(IdWorker.getId()); inventory.setCustomerId(request.customerId()); inventory.setOrderId(order.getId()); + inventory.setProfileId(profile.getId()); inventory.setSkuCode(input.skuCode()); inventory.setProductName(input.productName()); + inventory.setPackageCount(input.packageCount()); inventory.setProductQuantity(input.productQuantity()); + inventory.setCreatedAt(now); inventory.setUpdatedAt(now); + skuInventory.insert(inventory); + for (String mark : input.cartonMarks()) { + DropshipCarton carton = new DropshipCarton(); + carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId()); + carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null); + carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence); + carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now); + cartons.insert(carton); + ledger.insert(stockInFact(request, order, carton, now)); + } + } + } + return Math.toIntExact(totalPackages); + } + + private void validateMarks(DropshipRequests.Receive request) { + Set marks = new HashSet<>(); + Set palletNos = new HashSet<>(); + for (DropshipRequests.Pallet pallet : request.pallets()) { + if (!palletNos.add(pallet.palletNo().trim())) throw conflict("Duplicate pallet number: " + pallet.palletNo()); + Set skus = new HashSet<>(); + for (DropshipRequests.SkuPackages item : pallet.skuPackages()) { + if (item.packageCount() != item.cartonMarks().size()) { + throw conflict("SKU packageCount must equal cartonMarks count: " + item.skuCode()); + } + if (!skus.add(item.skuCode().trim())) throw conflict("Duplicate SKU on pallet: " + item.skuCode()); + for (String mark : item.cartonMarks()) { + if (!marks.add(mark.trim())) throw conflict("Duplicate carton mark: " + mark); + } + } + } + } + + private DropshipOrderProfile profile(DropshipRequests.Receive request, LastlegOrder order, + DropshipRequests.Pallet input, LocalDateTime now) { + DropshipOrderProfile profile = new DropshipOrderProfile(); + profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId()); + profile.setSourcePalletNo(input.palletNo().trim()); + profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L); + profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim()); + profile.setCreatedAt(now); profile.setUpdatedAt(now); + return profile; + } + + @Transactional + public void activateAfterStockIn(String customerId, Long orderId, Long taskId, + List receipts) { + List profilesForOrder = profiles.listOrder(customerId, orderId); + if (profilesForOrder.isEmpty()) return; + if (profilesForOrder.stream().allMatch(profile -> "ACTIVE".equals(profile.getStatus()))) return; + if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) { + throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying"); + } + Map> receiptsByPallet = receipts.stream() + .filter(receipt -> receipt.getWarehousePalletId() != null) + .collect(Collectors.groupingBy(InboundStockReceipt::getWarehousePalletId)); + if (receiptsByPallet.size() != profilesForOrder.size()) { + throw conflict("PDA pallet count does not match dropship source pallet count"); + } + List orderedProfiles = profilesForOrder.stream() + .sorted(java.util.Comparator.comparing(DropshipOrderProfile::getSourcePalletNo)).toList(); + List> orderedReceipts = receiptsByPallet.values().stream() + .sorted(java.util.Comparator.comparing(value -> value.get(0).getWarehousePalletId())).toList(); + LocalDateTime now = LocalDateTime.now(); + for (int index = 0; index < orderedProfiles.size(); index++) { + DropshipOrderProfile profile = orderedProfiles.get(index); + List actual = orderedReceipts.get(index); + List profileCartons = cartons.listProfile(customerId, profile.getId()); + long expected = profileCartons.size(); + long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum(); + if (expected != received) throw conflict("PDA package count does not match dropship carton snapshot"); + Long positionId = actual.get(0).getInventoryPositionId(); + if (actual.stream().anyMatch(value -> !java.util.Objects.equals(positionId, value.getInventoryPositionId()))) { + throw conflict("Dropship source pallet maps to multiple inventory positions"); + } + InboundStockReceipt first = actual.get(0); + profile.setWarehousePalletId(first.getWarehousePalletId()); profile.setPalletNo(first.getPalletNo()); + profile.setInventoryPositionId(positionId); profile.setStatus("ACTIVE"); profile.setVersion(value(profile.getVersion()) + 1); + profile.setUpdatedAt(now); profiles.updateById(profile); + for (DropshipCarton carton : profileCartons) { + carton.setWarehousePalletId(first.getWarehousePalletId()); carton.setPalletNo(first.getPalletNo()); + carton.setStatus("AVAILABLE"); carton.setVersion(value(carton.getVersion()) + 1); carton.setUpdatedAt(now); + cartons.updateById(carton); + DropshipCartonLedger activation = stockInActivation(customerId, orderId, taskId, carton, now); + ledger.insert(activation); + } + } + } + + private DropshipCartonLedger stockInActivation(String customerId, Long orderId, Long taskId, + DropshipCarton carton, LocalDateTime now) { + DropshipCartonLedger fact = new DropshipCartonLedger(); + fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(orderId); + fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode()); + fact.setOperationType("STOCK_IN"); fact.setBeforePackages(0L); fact.setDeltaPackages(1L); fact.setAfterPackages(1L); + fact.setIdempotencyKey("PDA_STOCK_IN:" + taskId + ":" + carton.getId()); fact.setOccurredAt(now); fact.setCreatedAt(now); + return fact; + } + + private DropshipCartonLedger stockInFact(DropshipRequests.Receive request, LastlegOrder order, + DropshipCarton carton, LocalDateTime now) { + DropshipCartonLedger fact = new DropshipCartonLedger(); + fact.setId(IdWorker.getId()); fact.setCustomerId(request.customerId()); fact.setOrderId(order.getId()); + fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode()); + fact.setOperationType("SNAPSHOT_CREATED"); fact.setBeforePackages(0L); fact.setDeltaPackages(0L); fact.setAfterPackages(0L); + fact.setBusinessOrderNo(order.getOrderNo()); + fact.setIdempotencyKey(request.idempotencyKey() + ":" + carton.getCartonMark()); + fact.setOccurredAt(now); fact.setCreatedAt(now); + return fact; + } + + private static long value(Long value) { return value == null ? 0L : value; } + + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java new file mode 100644 index 000000000..6c1285038 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dto; + +import com.baiyue.lastleg.entity.LastlegOrderDetail; + +import java.time.LocalDateTime; + +import java.util.List; + +public record LastlegOrderDetailResponse( + LastlegOrderSummary order, + List cargo, + List statusHistory +) { + public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java new file mode 100644 index 000000000..99bf49851 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class LastlegOrderRequests { + private LastlegOrderRequests() { + } + + public record Query( + @Min(1) long page, + @Min(1) @Max(200) long size, + String orderNo, + String orderType, + String inboundCode, + String sourceOrderNo, + String containerNo, + String currentStatus, + String status, + String warehouse, + String transferWarehouseCode, + Boolean abnormal, + Boolean detained, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public Query { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + + public record TransferDestination( + @NotEmpty @Size(max = 200) List orderIds, + @NotBlank @Size(max = 128) String warehouseId, + @NotBlank @Size(max = 128) String warehouseCode, + @NotBlank @Size(max = 255) String warehouseName, + @Size(max = 500) String remark + ) { + } + + public record SingleTransferDestination( + @NotBlank @Size(max = 128) String warehouseId, + @NotBlank @Size(max = 128) String warehouseCode, + @NotBlank @Size(max = 255) String warehouseName, + @Size(max = 500) String remark + ) { + public TransferDestination withOrderId(long orderId) { + return new TransferDestination(List.of(orderId), warehouseId, warehouseCode, warehouseName, remark); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java new file mode 100644 index 000000000..fb5b95346 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "按入仓单编码粒度展示的尾程订单列表行") +public record LastlegOrderSummary( + Long id, + String orderNo, + String orderType, + String orderTypeName, + @Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"}) + String destinationType, + String sourceInboundId, + String inboundCode, + String sourceOrderNo, + String customerId, + String customerName, + String overseasWarehouseId, + String overseasWarehouseName, + String transferWarehouseId, + String transferWarehouseCode, + String transferWarehouseName, + String fbaWarehouseCode, + String containerNo, + String containerStatus, + Boolean abnormal, + Boolean detained, + Integer palletCount, + Integer packageCount, + BigDecimal volumeCbm, + Integer productQuantity, + BigDecimal volumeWeightKg, + LocalDateTime estimatedArrivalAt, + LocalDateTime actualArrivalAt, + LocalDateTime estimatedWarehouseAt, + String inboundOrderNo, + String relabelOrderNo, + String relabelStatus, + String palletMergeFlag, + String currentStatus, + String subOrderStatus, + String privateRemark, + String customerRemark, + String warehouseRemark, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java new file mode 100644 index 000000000..5f6f3b270 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java @@ -0,0 +1,5 @@ +/** + * API request and response models for last-leg delivery. + * / 尾程配送 API 请求与响应模型。 + */ +package com.baiyue.lastleg.dto; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java new file mode 100644 index 000000000..fac02ba0c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; +@Data @TableName("ll_loading_event_inbox") public class LastlegEventInbox { @TableId private String eventId; private String customerId; private String eventType; private Integer eventVersion; private String payloadHash; private String payloadJson; private String status; private LocalDateTime receivedAt; private LocalDateTime processedAt; private String lastError; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java new file mode 100644 index 000000000..032425af3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.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; + +@Data +@TableName("ll_event_outbox") +public class LastlegEventOutbox { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String aggregateType; + private String aggregateId; + private String eventType; + private Integer eventVersion; + private String eventKey; + private String payloadJson; + private String status; + private Integer retryCount; + private LocalDateTime nextRetryAt; + private LocalDateTime publishedAt; + private LocalDateTime lockedAt; + private String lockedBy; + private String lastError; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java new file mode 100644 index 000000000..7c7ce0bd6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("ll_inbound_order") +public class LastlegInboundOrder { + @TableId private Long id; + private String customerId; + private String sourceRegion; + private String sourceInboundId; + private String rootInboundId; + private String sourceOrderId; + private String sourceOrderNo; + private String rootOrderId; + private String inboundCode; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String destinationType; + private Integer packageCount; + private Integer productQuantity; + private Integer palletCount; + private BigDecimal grossWeightKg; + private BigDecimal netWeightKg; + private BigDecimal volumeCbm; + private String sourceEventId; + private String sourceLoadingPlanId; + private Long sourceLoadingPlanVersion; + private Long handoverVersion; + private String payloadHash; + private String status; + private String exceptionFlag; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java new file mode 100644 index 000000000..89234d307 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.entity; + +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_order") +public class LastlegOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String orderNo; + private String customerId; + private String orderType; + private String destinationType; + private String inboundCode; + private String sourceRegion; + private String sourceInboundId; + private String rootInboundId; + private String sourceOrderId; + private String sourceOrderNo; + private String rootOrderId; + private String customerName; + private String sourceWarehouseId; + private String sourceWarehouseName; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String fbaWarehouseCode; + private String containerNo; + private String transferWarehouseId; + private String transferWarehouseCode; + private String transferWarehouseName; + private String containerStatus; + private Boolean abnormal; + private Boolean detained; + private Integer palletCount; + private Integer packageCount; + private Integer pickedPackageCount; + private Integer shippedPackageCount; + private BigDecimal volumeCbm; + private Integer productQuantity; + private BigDecimal volumeWeightKg; + private LocalDateTime estimatedArrivalAt; + private LocalDateTime actualArrivalAt; + private LocalDateTime estimatedWarehouseAt; + private String inboundOrderNo; + private String outboundOrderNo; + private String deliveryOrderNo; + private String relabelOrderNo; + private String relabelStatus; + private String palletMergeFlag; + private String currentStatus; + private String subOrderStatus; + private String privateRemark; + private String customerRemark; + private String warehouseRemark; + private String sourceEventId; + private String sourceLoadingPlanId; + private String sourceContainerBookingId; + private Long sourceLoadingPlanVersion; + private Long sourceHandoverVersion; + private String sourcePayloadHash; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java new file mode 100644 index 000000000..b18b16cd1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +@Data @TableName("ll_order_detail") public class LastlegOrderDetail { + @TableId(type=IdType.AUTO) private Long id; private String customerId; private Long orderId; + private String sourceInboundDetailId; private String skuCode; private String productNameZh; private String productNameEn; + private Integer packageCount; private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; + private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; + private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; + private String fbaTrackingNo; private String shipmentNo; private String shippingMark; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java new file mode 100644 index 000000000..6577b61fa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; +@Data @TableName("ll_loading_order_status_history") public class LastlegOrderStatusHistory { + @TableId(type=IdType.AUTO) private Long id; private String customerId; private Long orderId; + private String fromStatus; private String toStatus; private String eventId; private String remark; private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java new file mode 100644 index 000000000..95c097114 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java @@ -0,0 +1,5 @@ +/** + * Persistence entities mapped to last-leg MySQL tables. + * / 映射尾程 MySQL 数据表的持久化实体。 + */ +package com.baiyue.lastleg.entity; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java new file mode 100644 index 000000000..a165bf4b2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan({"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper"}) +public class InboundPlanMapperConfiguration { +} 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 new file mode 100644 index 000000000..3f8c3da74 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.inboundplan.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; +import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inbound-plans") +@Tag(name = "尾程入仓计划") +public class InboundPlanController { + @Resource private InboundPlanService service; + @Resource private InboundFbaAppointmentService appointmentService; + + /** 按入仓计划明细分页查询可预约的入库订单行。 */ + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "分页查询入库订单", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InboundPlanRequests.Query query) { + return ApiResponse.success(service.page(scope(user, customerId), query)); + } + + @PostMapping("/convert") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") + /** 由平台人工将待分配尾程订单转换为入仓计划。 */ + @Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: " + + OverseasPermissions.INBOUND_ORDER_CREATE) + public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ConvertInboundPlanRequest request) { + return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); + } + + @GetMapping("/{planNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + /** 查询入仓计划及其明细、托盘和分配信息。 */ + @Operation(summary = "查询入仓计划详情", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo)); + } + + @PostMapping("/{planNo}/pallets/supplement") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + /** 为计划目标仓新增一个可打印的补充托盘。 */ + @Operation(summary = "新增补充托盘", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) + public ApiResponse supplementPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @Valid @RequestBody SupplementPalletRequest request) { + return ApiResponse.success(service.supplementPallet(scope(user, request.customerId()), planNo, request)); + } + + @PostMapping("/fba-appointments/batch") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT + "')") + /** 为选中的入仓明细创建 FBA 预约。 */ + @Operation(summary = "创建 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT) + public ApiResponse batchFbaAppointment( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody BatchFbaAppointmentRequest request) { + String customerId = scope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(appointmentService.batchCreate(customerId, operatorId, request)); + } + + @GetMapping("/fba-appointments/{appointmentNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + /** 查询 FBA 预约及其选中的入仓明细。 */ + @Operation(summary = "查询 FBA 预约详情", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) + public ApiResponse fbaAppointmentDetail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String appointmentNo, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo)); + } + + @PutMapping("/fba-appointments/{appointmentNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT + "')") + @Operation(summary = "编辑 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT) + public ApiResponse updateFbaAppointment( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String appointmentNo, + @Valid @RequestBody UpdateFbaAppointmentRequest request) { + return ApiResponse.success(appointmentService.update(scope(user, request.customerId()), + appointmentNo, request)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + return null; + } + return requestedCustomer.trim(); + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java new file mode 100644 index 000000000..7547e0bc3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public record BatchFbaAppointmentRequest( + String customerId, + @NotEmpty @Size(max = 500) List<@NotNull Long> lineIds, + @NotBlank @Size(max = 64) String appointmentNo, + @NotNull LocalDateTime appointmentAt, + @NotNull LocalDateTime expectedLoadingAt, + @NotBlank @Size(max = 32) String appointmentStatus, + @Size(max = 1000) String appointmentRemark +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java new file mode 100644 index 000000000..8d4a6a20b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotNull; + +public record ConvertInboundPlanRequest( + @NotNull Long orderId, + String customerId +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java new file mode 100644 index 000000000..4c6c31d8d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record FbaAppointmentResponse( + Long id, + String customerId, + String appointmentNo, + LocalDateTime appointmentAt, + LocalDateTime expectedLoadingAt, + String appointmentStatus, + String appointmentRemark, + String fbaWarehouseCode, + int selectedCount, + String createdBy, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List items +) { + public record Item(Long id, Long planId, Long lineId, Long orderId, String inboundCode, + String fbaWarehouseCode) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java new file mode 100644 index 000000000..5a4e1c6a5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record InboundPlanListResponse( + Long lineId, + Long planId, + String planNo, + String customerId, + Long orderId, + String inboundCode, + String orderType, + String warehouseId, + String warehouseCode, + String warehouseName, + String fbaWarehouseCode, + String customerName, + Integer packageCount, + BigDecimal volumeCbm, + Integer palletCount, + String status, + String appointmentNo, + String appointmentStatus, + LocalDateTime appointmentAt, + LocalDateTime expectedLoadingAt, + String dispatchStatus, + LocalDateTime createdAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java new file mode 100644 index 000000000..ae1024497 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDateTime; + +public final class InboundPlanRequests { + private InboundPlanRequests() { + } + + public record Query( + @Min(1) long page, + @Min(1) @Max(200) long size, + String planNo, + String inboundCode, + String status, + String overseasWarehouseId, + String destinationWarehouseCode, + String warehouse, + String fbaWarehouseCode, + @Pattern(regexp = "UNAPPOINTED|PENDING|SUCCESS|FAILED|CANCELLED") + String appointmentStatus, + @Pattern(regexp = "UNSCHEDULED|SCHEDULED") + String dispatchStatus, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public Query { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java new file mode 100644 index 000000000..e4e1c5e70 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record InboundPlanResponse( + Long id, + String planNo, + String customerId, + Long sourceOrderId, + String containerNo, + String status, + int lineCount, + int palletCount, + BigDecimal totalVolumeCbm, + List lines, + List pallets, + List allocations +) { + public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) { + } + + public record Pallet(Long id, String palletNo, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo, + Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId, + String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, + int actualPackageCount) { + } + + public record Allocation(Long lineId, Long palletId, BigDecimal allocatedVolumeCbm, + int plannedPackageCount, int countedPackageCount, int pickedPackageCount, + BigDecimal actualVolumeCbm, String status, int version) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java new file mode 100644 index 000000000..e386e184c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "Inbound plan row for the platform browser API; outbound workflow fields are intentionally excluded") +public record InboundPlanSummary( + Long id, + String planNo, + Long sourceOrderId, + String customerId, + String customerName, + String orderType, + String inboundCode, + String containerNo, + String overseasWarehouseId, + String overseasWarehouseName, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + String fbaWarehouseCode, + Integer packageCount, + BigDecimal totalVolumeCbm, + Integer palletCount, + String status, + String appointmentStatus, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java new file mode 100644 index 000000000..47aa5b910 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 托盘纸不足时,按计划内目的仓补打一张空托盘标签。 */ +public record SupplementPalletRequest(@NotBlank String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String customerId) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateFbaAppointmentRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateFbaAppointmentRequest.java new file mode 100644 index 000000000..6f7076fe5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateFbaAppointmentRequest.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +public record UpdateFbaAppointmentRequest( + String customerId, + @NotNull LocalDateTime appointmentAt, + @NotNull LocalDateTime expectedLoadingAt, + @NotBlank @Size(max = 32) String appointmentStatus, + @Size(max = 1000) String appointmentRemark +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java new file mode 100644 index 000000000..fc075215b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.inboundplan.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; + +@Data +@TableName("ll_inbound_fba_appointment") +public class InboundFbaAppointment { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String appointmentNo; + private LocalDateTime appointmentAt; + private LocalDateTime expectedLoadingAt; + private String appointmentStatus; + private String appointmentRemark; + private String fbaWarehouseCode; + private Integer selectedCount; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java new file mode 100644 index 000000000..96a262753 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inboundplan.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; + +@Data +@TableName("ll_inbound_fba_appointment_item") +public class InboundFbaAppointmentItem { + @TableId(type = IdType.INPUT) + private Long id; + private Long appointmentId; + private Long planId; + private Long lineId; + private Long orderId; + private String inboundCode; + private String fbaWarehouseCode; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java new file mode 100644 index 000000000..79ffac343 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inboundplan.entity; + +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_inbound_plan") +public class InboundPlan { + @TableId(type = IdType.INPUT) + private Long id; + private String planNo; + private String customerId; + private Long sourceOrderId; + private String containerNo; + private Long overseasWarehouseId; + private String status; + private Integer lineCount; + private Integer palletCount; + private BigDecimal totalVolumeCbm; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java new file mode 100644 index 000000000..eea6b53da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.inboundplan.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; + +/** 头程装柜完成时冻结并关联尾程订单、由海外扫码补充托盘归属的箱级事实。 */ +@Data +@TableName("ll_inbound_plan_carton") +public class InboundPlanCarton { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long orderId; + private String sourceInboundDetailId; + private String skuCode; + private String cartonMark; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private Long planId; + private Long lineId; + private Long planPalletId; + private Long warehousePalletId; + private Long warehouseLocationId; + private Long pdaTaskId; + private String status; + private Integer version; + private LocalDateTime boundAt; + private LocalDateTime receivedAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java new file mode 100644 index 000000000..6e8cd25d4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inboundplan.entity; + +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; + +@Data +@TableName("ll_inbound_plan_line") +public class InboundPlanLine { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long planId; + private Long orderId; + private String inboundCode; + private String destinationType; + private String fbaWarehouseCode; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private BigDecimal volumeCbm; + private Integer requiredPackageCount; + private Integer plannedPalletCount; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java new file mode 100644 index 000000000..28eea73e4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.entity; + +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_inbound_plan_pallet") +public class InboundPlanPallet { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long planId; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private BigDecimal capacityCbm; + private BigDecimal allocatedVolumeCbm; + private Integer sequenceNo; + private Long warehousePalletId; + private Long warehouseLocationId; + private Long warehouseResourceLockId; + private String assignmentStatus; + private LocalDateTime lockedAt; + private BigDecimal actualVolumeCbm; + private Integer actualPackageCount; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java new file mode 100644 index 000000000..a828873fe --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.inboundplan.entity; + +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; + +@Data +@TableName("ll_inbound_plan_pallet_allocation") +public class InboundPlanPalletAllocation { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long planId; + private Long lineId; + private Long palletId; + private BigDecimal allocatedVolumeCbm; + private Integer plannedPackageCount; + private Integer countedPackageCount; + private Integer pickedPackageCount; + private BigDecimal actualVolumeCbm; + private String status; + private Integer version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java new file mode 100644 index 000000000..d80900dc6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundFbaAppointmentItemMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java new file mode 100644 index 000000000..c94458295 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundFbaAppointmentMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java new file mode 100644 index 000000000..e2972ecba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot; +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 InboundFbaAppointmentQueryMapper { + @Select("") + List lockLines(@Param("customerId") String customerId, + @Param("lineIds") List lineIds); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java new file mode 100644 index 000000000..742781c8a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +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 InboundPlanCartonMapper extends BaseMapper { + @Select(""" + SELECT l.* FROM ll_inbound_plan_line l + JOIN ll_order_detail d ON d.order_id=l.order_id + WHERE l.plan_id=#{planId} + AND d.source_inbound_detail_id=#{sourceInboundDetailId} + LIMIT 1 + """) + com.baiyue.lastleg.inboundplan.entity.InboundPlanLine findPlanLine(@Param("planId") Long planId, + @Param("sourceInboundDetailId") String sourceInboundDetailId); + + @Select(""" + SELECT COUNT(*) FROM ll_inbound_plan_carton + WHERE plan_id=#{planId} AND line_id=#{lineId} AND status='ON_PALLET' + """) + long scannedCount(@Param("planId") Long planId, @Param("lineId") Long lineId); + + @Select(""" + SELECT EXISTS(SELECT 1 FROM ll_inbound_plan_line l + JOIN ll_order_detail d ON d.order_id=l.order_id + JOIN ll_inbound_plan_carton c ON c.source_inbound_detail_id=d.source_inbound_detail_id + WHERE l.plan_id=#{planId}) + """) + boolean hasLabelPool(@Param("planId") Long planId); + + @Select(""" + SELECT DISTINCT c.* FROM ll_inbound_plan_carton c + JOIN ll_order_detail d ON d.source_inbound_detail_id=c.source_inbound_detail_id + JOIN ll_inbound_plan_line l ON l.order_id=d.order_id + WHERE l.plan_id=#{planId} AND c.customer_id=l.customer_id + ORDER BY c.id + """) + List listExpected(@Param("planId") Long planId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java new file mode 100644 index 000000000..d7480e0d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanLineMapper extends BaseMapper { +} 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 new file mode 100644 index 000000000..534969f23 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface InboundPlanMapper extends BaseMapper { + String PAGE_FROM = " FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " + + "LEFT JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.order_id=p.source_order_id " + + "WHERE p.customer_id=#{customerId}" + + " AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')" + + " AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND p.status=#{q.status}" + + " AND o.overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND l.destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND l.fba_warehouse_code=#{q.fbaWarehouseCode}" + + " " + + "AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " + + "WHERE ai.plan_id=p.id AND a.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})" + + " AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " + + "WHERE ai.plan_id=p.id)" + + " AND p.created_at >=#{q.createdFrom}" + + " AND p.created_at <=#{q.createdTo}"; + + @Select("") + List pageRows(@Param("customerId") String customerId, + @Param("q") InboundPlanRequests.Query query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countRows(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query); + + @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); + + @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})") + int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("planId") long planId, + @Param("warehouseId") long 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 new file mode 100644 index 000000000..56d3dda53 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface InboundPlanOrderMapper { + @Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") + String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId); + + @Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," + + "o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," + + "COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," + + "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," + + "COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," + + "SUM(CASE WHEN d.id IS NOT NULL " + + "AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " + + "AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " + + "<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " + + "AS destinationMismatchCount " + + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + + "WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " + + "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," + + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + + "ORDER BY o.id FOR UPDATE") + List lockOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); + + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + + "pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{orderId}") + int markPlanned(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("planNo") String planNo, @Param("palletCount") int palletCount, + @Param("updatedAt") LocalDateTime updatedAt); + + @Insert("INSERT INTO ll_loading_order_status_history(customer_id,order_id,from_status,to_status,remark,created_at) " + + "VALUES(#{customerId},#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})") + int insertHistory(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("fromStatus") String fromStatus, + @Param("remark") String remark, @Param("createdAt") LocalDateTime createdAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java new file mode 100644 index 000000000..a2c825b35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanPalletAllocationMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java new file mode 100644 index 000000000..c700504e8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanPalletMapper extends BaseMapper { +} 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 new file mode 100644 index 000000000..ce4f67d05 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +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 InboundPlanQueryMapper { + 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" + + " AND p.customer_id=#{customerId} AND o.customer_id=#{customerId}" + + " LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.line_id=l.id" + + " AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2" + + " JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id" + + " WHERE ai2.line_id=l.id AND a2.customer_id=#{customerId}" + + " ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1)" + + " LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id AND a.customer_id=#{customerId}" + + " WHERE 1=1" + + " AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')" + + " AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND p.status=#{q.status}" + + " AND l.destination_warehouse_id=#{q.overseasWarehouseId}" + + " AND l.destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))" + + " 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.order_id=l.order_id AND di.customer_id=#{customerId})" + + " AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id AND di.customer_id=#{customerId})" + + " AND p.created_at >=#{q.createdFrom}" + + " AND p.created_at <=#{q.createdTo}"; + + @Select("") + List rows(@Param("customerId") String customerId, + @Param("q") InboundPlanRequests.Query query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long count(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java new file mode 100644 index 000000000..2244c4d85 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.inboundplan.model; + +public record FbaAppointmentLineSnapshot( + Long lineId, + Long planId, + Long orderId, + String inboundCode, + String fbaWarehouseCode +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java new file mode 100644 index 000000000..99644373d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.inboundplan.model; + +public enum FbaAppointmentStatus { + PENDING, + SUCCESS, + FAILED, + CANCELLED +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java new file mode 100644 index 000000000..b4d7d5886 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.model; + +import java.math.BigDecimal; + +public record SourceOrderSnapshot( + Long orderId, + String customerId, + String containerNo, + String inboundCode, + String currentStatus, + String inboundOrderNo, + BigDecimal volumeCbm, + int packageCount, + String fbaWarehouseCode, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + int destinationCount, + int destinationMismatchCount +) { + public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode, + String currentStatus, String inboundOrderNo, BigDecimal volumeCbm, + int packageCount, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int destinationCount, int destinationMismatchCount) { + this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm, + packageCount, null, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, destinationCount, destinationMismatchCount); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java new file mode 100644 index 000000000..f5aa7e7d8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -0,0 +1,153 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; +import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/receiving-containers") +@Tag(name = "Overseas PDA container receiving") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class ReceivingContainerController { + public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION; + @Resource private ReceivingContainerService service; + + @GetMapping("/candidates") + @Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user) { + PdaWarehouseIdentity identity = identity(user); + return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); + } + + @GetMapping("/warehouse-profile") + @Operation(summary = "Get the authenticated warehouse picker's current Chinese warehouse master name") + public ApiResponse warehouseProfile( + @AuthenticationPrincipal AuthenticatedUser user) { + PdaWarehouseIdentity identity = identity(user); + return ApiResponse.success(service.warehouseProfile(identity.overseasWarehouseId())); + } + + @GetMapping("/{containerNo}") + @Operation(summary = "Get one receiving container without exposing plan or allocation IDs") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String containerNo) { + PdaWarehouseIdentity identity = identity(user); + return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo)); + } + + @PostMapping("/{containerNo}/prepare") + public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { + PdaWarehouseIdentity identity = identity(user); + return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), + containerNo, request)); + } + + @PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @RequestPart("file") MultipartFile file) { + PdaWarehouseIdentity identity = currentIdentity(user); + if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required"); + return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file)); + } + + @GetMapping("/current/unload-photos/files/{fileId}/download") + public ResponseEntity downloadCurrentPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String fileId) { + PdaWarehouseIdentity identity = currentIdentity(user); + var download = service.downloadCurrentPhoto(identity.operatorKey(), fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.inline().filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(download.contentType())) + .contentLength(download.content().length).body(download.content()); + } + + @PostMapping("/current/sign") + public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.Sign request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request)); + } + + @GetMapping("/current") + public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser user) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.current(identity.operatorKey())); + } + + @PostMapping("/current/cartons/resolve") + public ApiResponse resolveCarton( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.CartonResolve request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request)); + } + + @PostMapping("/current/pallets/resolve") + public ApiResponse resolvePallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request)); + } + + @PostMapping("/current/receipts/bulk") + public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.BulkReceive request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request)); + } + + @PostMapping("/current/receipts/carton") + public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.CartonReceive request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request)); + } + + @PostMapping("/current/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReceivingContainerRequests.Action request) { + PdaWarehouseIdentity identity = currentIdentity(user); + return ApiResponse.success(service.complete(identity.operatorKey(), request)); + } + + private static PdaWarehouseIdentity identity(AuthenticatedUser user) { + return PdaWarehouseIdentity.require(user); + } + + private PdaWarehouseIdentity currentIdentity(AuthenticatedUser user) { + PdaWarehouseIdentity identity = identity(user); + service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId()); + return identity; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java new file mode 100644 index 000000000..018e04692 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; + +import java.util.List; + +public final class PdaRequests { + private PdaRequests() {} + + public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record Assign(@Positive long warehouseId, @Positive long palletId, @Positive long locationId, + @NotBlank String idempotencyKey) {} + + public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey, + String operatorId, String operatorName) {} + + public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount, + @NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record ScanCarton(@NotBlank String cartonMark, @Positive long planPalletId, + @NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey, + String operatorId, String operatorName) {} + + public record UnloadPhotos(@NotEmpty List<@Positive Long> fileIds, @NotBlank String idempotencyKey, + String operatorId, String operatorName) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java new file mode 100644 index 000000000..ca79c2b25 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaResponses { + private PdaResponses() {} + + public record Task(long taskId, String taskNo, String planNo, String status, + LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, + List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, + LocalDateTime estimatedEmptyReturnAt, + List pallets, List allocations, + List expectedCartons) {} + + public record Pallet(long planPalletId, String palletNo, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + long warehousePalletId, long warehouseLocationId, + String assignmentStatus, int actualPackageCount) {} + + public record Allocation(long allocationId, long lineId, long planPalletId, + long orderId, String inboundCode, String fbaWarehouseCode, + int requiredPackageCount, int plannedPackageCount, + int countedPackageCount, int pickedPackageCount, + String status, int version) {} + + public record ExpectedCarton(String cartonMark, String skuCode, + String destinationWarehouseId, String destinationWarehouseCode, + long planPalletId, String status) {} + + public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {} + + public record Operation(String result, long allocationId, int requiredPackageCount, + int countedPackageCount, int pickedPackageCount, + BigDecimal actualVolumeCbm, Integer remainingPackageCount, + Integer requestedPackageCount, Integer mergePackageCount, + List candidates) {} + + public record MergeCandidate(long allocationId, long palletId, String palletNo, long lineId, + long orderId, String inboundCode, int availablePackageCount, + BigDecimal availableVolumeCbm) {} + + public record Merge(String result, String mergeGroupNo, long sourceOrderId, long targetOrderId, + int movedPackageCount, BigDecimal movedVolumeCbm, + boolean sourcePalletScrapped) {} + + public record Carton(String result, String cartonMark, long planPalletId, long allocationId, + int palletActualPackageCount) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java new file mode 100644 index 000000000..6d06a96c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class ReceivingContainerRequests { + private ReceivingContainerRequests() { } + + public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { } + public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos, + @NotBlank @Size(max = 255) String idempotencyKey) { } + public record SignedPhoto(@NotNull ReceivingPhotoRole role, + @NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { } + public record CartonResolve(@NotBlank String cartonMark) { } + public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { } + public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode, + @Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { } + public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, + @NotBlank @Size(max = 255) String idempotencyKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java new file mode 100644 index 000000000..6afe67cba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class ReceivingContainerResponses { + private ReceivingContainerResponses() { } + + public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount, + List destinationCodes, int plannedPalletCount, + int totalPackages, int receivedPackages, boolean currentAccount) { } + + public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { } + + public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName, + String status, String operatorName, int version, + List photos, LocalDateTime preparedAt, LocalDateTime signedAt, + LocalDateTime receivingAt, LocalDateTime completedAt, + int totalPackages, int receivedPackages, int pendingPackages, + List inbounds) { } + + public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { } + + public record Inbound(String inboundCode, String destinationType, + String overseasWarehouseId, String overseasWarehouseName, + String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + String fbaWarehouseCode, int plannedPalletCount, int totalPackages, + int receivedPackages, int pendingPackages) { } + + public record CartonResolved(String cartonMark, String inboundCode, + String destinationType, String overseasWarehouseId, + String overseasWarehouseName, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + String fbaWarehouseCode, int plannedPalletCount, int totalPackages, + int receivedPackages, int pendingPackages) { } + + public record PalletResolved(String palletCode, long warehouseId, String status, + String fbaWarehouseCode, String inboundCode, + String destinationType, boolean compatible) { } + + public record Receipt(String result, String containerNo, String inboundCode, + String destinationType, String palletCode, int acceptedPackages, + int receivedPackages, int pendingPackages, String sessionStatus) { } + + public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingPhotoRole.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingPhotoRole.java new file mode 100644 index 000000000..211113644 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingPhotoRole.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +public enum ReceivingPhotoRole { + LICENSE_PLATE, + CONTAINER_SEAL, + DOOR_OPEN +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java new file mode 100644 index 000000000..0e70e49bb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +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_inbound_pallet_merge") +public class InboundPalletMerge { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long taskId; + private String mergeGroupNo; + private Long sourcePalletId; + private Long targetPalletId; + private Long sourceAllocationId; + private Long targetAllocationId; + private Long sourceLineId; + private Long targetLineId; + private Long sourceOrderId; + private Long targetOrderId; + private Integer movedPackageCount; + private BigDecimal movedVolumeCbm; + private Boolean sourcePalletScrapped; + private String status; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java new file mode 100644 index 000000000..2a95ef792 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.pda.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; + +@Data +@TableName("ll_inbound_pda_operation") +public class InboundPdaOperation { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long taskId; + private Long allocationId; + private String action; + private String idempotencyKey; + private String requestHash; + private String requestJson; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java new file mode 100644 index 000000000..42dad8e55 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.pda.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; + +@Data +@TableName("ll_inbound_pda_task") +public class InboundPdaTask { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private String customerId; + private String taskNo; + private String status; + private String operatorId; + private String operatorName; + private LocalDateTime pushedAt; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private String unloadPhotoFileIds; + private LocalDateTime unloadPhotoUploadedAt; + private LocalDateTime estimatedEmptyReturnAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Integer version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java new file mode 100644 index 000000000..410985cdc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.inboundplan.pda.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 inbound-order/pallet package receipt created when a PDA task completes. */ +@Data +@TableName("ll_inbound_stock_receipt") +public class InboundStockReceipt { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long planId; + private Long taskId; + private Long allocationId; + private Long lineId; + private Long orderId; + private String inboundCode; + private String skuCode; + private String productNameZh; + private String productNameEn; + private Long warehousePalletId; + private String palletNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private String batchNo; + private Long receivedPackages; + private Long inventoryBalanceId; + private Long inventoryPositionId; + private String inventoryOperationKey; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java new file mode 100644 index 000000000..18d2bfcac --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.pda.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; + +@Data +@TableName("ll_receiving_container_operation") +public class ReceivingContainerOperation { + @TableId(type = IdType.INPUT) + private Long id; + private Long sessionId; + private String customerId; + private String operatorKey; + private String action; + private String idempotencyKey; + private String requestHash; + private String requestJson; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java new file mode 100644 index 000000000..388bcf420 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_receiving_container_session") +public class ReceivingContainerSession { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String containerNo; + private Long overseasWarehouseId; + private String overseasWarehouseName; + private String operatorKey; + private String operatorName; + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String activeOperatorKey; + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String activeContainerKey; + private String status; + private String signPhotos; + private LocalDateTime preparedAt; + private LocalDateTime signedAt; + private LocalDateTime receivingAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @Version + private Integer version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingSkuReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingSkuReceipt.java new file mode 100644 index 000000000..91ab80bf0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingSkuReceipt.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_receiving_sku_receipt") +public class ReceivingSkuReceipt { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long sessionId; + private Long planId; + private Long lineId; + private Long palletId; + private Long orderId; + private String inboundCode; + private String skuCode; + private String productName; + private Integer receivedPackages; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @Version + private Integer version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java new file mode 100644 index 000000000..f1f69210f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java @@ -0,0 +1,155 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.core.io.ByteArrayResource; +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; + +@Component +public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getUnloadPhotoFile()); + } + + @Override + public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { + long tenantId = tenantId(customerId); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new IllegalArgumentException("Cannot read unload photo", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId, BUSINESS_TYPE, taskId) + .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Unload photo upload failed"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Unload photo upload failed"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "File service returned no photo metadata"); + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long taskId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Unload photo does not exist or its ownership is invalid"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Unload photo does not exist or its ownership is invalid"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() != fileId + || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Unload photo metadata does not match the PDA task"); + } + } + + @Override + public DownloadedFile download(String customerId, long taskId, long fileId) { + long tenantId = tenantId(customerId); + ResponseEntity response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content", + tenantId, BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, (request, result) -> { + HttpStatusCode status = result.getStatusCode(); + if (status.value() == HttpStatus.NOT_FOUND.value()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Unload photo was not found"); + } + if (status.value() == HttpStatus.FORBIDDEN.value()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "Unload photo is not in current account scope"); + } + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo download failed"); + }) + .onStatus(HttpStatusCode::is5xxServerError, (request, result) -> { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Unload photo download failed"); + }) + .toEntity(byte[].class); + byte[] content = response.getBody() == null ? new byte[0] : response.getBody(); + String fileName = contentDispositionFilename(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)); + if (fileName == null || fileName.isBlank()) { + fileName = String.valueOf(fileId) + ".bin"; + } + String contentType = response.getHeaders().getContentType() == null + ? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString(); + return new DownloadedFile(content, fileName, contentType); + } + + private long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); } + } + + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private static String contentDispositionFilename(String rawValue) { + if (rawValue == null || rawValue.isBlank()) return null; + try { + return ContentDisposition.parse(rawValue).getFilename(); + } catch (IllegalArgumentException ignored) { + return rawValue.replace("attachment; filename=\"", "").replace("\"", "").trim(); + } + } + + private record MetadataEnvelope(String code, String message, Metadata data) {} + private 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/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java new file mode 100644 index 000000000..ea792ea46 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -0,0 +1,183 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import com.fasterxml.jackson.annotation.JsonAlias; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.server.ResponseStatusException; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.util.Map; + +@Component +public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + + @Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") + private String baseUrl; + + @Value("${baiyue.security.internal-service-token:}") + private String internalToken; + + private RestClient client; + + @PostConstruct + void initializeClient() { + this.client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getWarehouseResource()); + } + + @Override + public WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId) { + WarehousePalletResponse response = client.post().uri("/internal/v1/warehouse-pallets/ensure-dropship") + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId), "warehouseId", warehouseId, "businessId", businessId)) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode()); + }).body(WarehousePalletResponse.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response"); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), + response.data().warehouseId(), response.data().locationId(), response.data().status(), + response.data().fbaWarehouseCode(), response.data().version()); + } + + @Override + public long reserve(String customerId, long warehouseId, long palletId, long locationId, + String businessType, String businessId, String reservationKey) { + ReserveEnvelope response = client.post() + .uri("/internal/v1/warehouse-resource-locks") + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId), "warehouseId", warehouseId, + "palletId", palletId, "locationId", locationId, "businessType", businessType, + "businessId", businessId, "reservationKey", reservationKey)) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalStateException("Warehouse rejected pallet reservation: HTTP " + result.getStatusCode()); + }) + .body(ReserveEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty lock response"); + return response.data().lockId(); + } + + @Override + public void scrap(String customerId, long resourceLockId) { + client.post() + .uri("/internal/v1/warehouse-resource-locks/{lockId}/scrap", resourceLockId) + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId))) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode()); + }) + .toBodilessEntity(); + } + + @Override + public void release(String customerId, long resourceLockId) { + client.post() + .uri("/internal/v1/warehouse-resource-locks/{lockId}/release", resourceLockId) + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId))) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + throw new IllegalStateException("Warehouse rejected pallet release: HTTP " + response.getStatusCode()); + }) + .toBodilessEntity(); + } + + @Override + public void scrapPallet(String customerId, long palletId) { + client.post() + .uri("/internal/v1/warehouse-resource-locks/pallets/{palletId}/scrap", palletId) + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId))) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode()); + }) + .toBodilessEntity(); + } + + @Override + public WarehousePalletReference resolvePallet(String palletCode) { + WarehousePalletResponse response = client.get() + .uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Warehouse rejected pallet code: HTTP " + result.getStatusCode()); + }).body(WarehousePalletResponse.class); + if (response == null || response.data() == null) throw new IllegalArgumentException("Warehouse returned an empty pallet response"); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), + response.data().warehouseId(), response.data().locationId(), response.data().status(), + response.data().fbaWarehouseCode(), response.data().version()); + } + + @Override + public WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { + WarehouseLocationResponse response = client.get() + .uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/resolve/{locationCode}") + .queryParam("warehouseId", warehouseId).build(locationCode)) + .header("X-Internal-Service-Token", internalToken).retrieve() + .body(WarehouseLocationResponse.class); + return location(response); + } + + @Override + public WarehouseLocationReference resolveLocation(long warehouseId, long locationId) { + WarehouseLocationResponse response = client.get() + .uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/{locationId}") + .queryParam("warehouseId", warehouseId).build(locationId)) + .header("X-Internal-Service-Token", internalToken).retrieve() + .body(WarehouseLocationResponse.class); + return location(response); + } + + private WarehouseLocationReference location(WarehouseLocationResponse response) { + if (response == null || response.data() == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse location was not found"); + } + return response.data(); + } + + @Override + public WarehouseReference requireActiveSharedWarehouse(long warehouseId) { + WarehouseReferenceResponse response = client.get() + .uri("/internal/v1/warehouse-resource-locks/shared-warehouses/{warehouseId}", warehouseId) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().value() == 404 + ? HttpStatus.NOT_FOUND : (result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE), + "Shared overseas warehouse lookup failed"); + }).body(WarehouseReferenceResponse.class); + if (response == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Shared overseas warehouse service returned no response"); + } + if ("A0404".equals(response.code())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Active shared overseas warehouse was not found"); + } + if (!"200".equals(response.code()) || response.data() == null + || response.data().warehouseId() != warehouseId || !"ACTIVE".equals(response.data().status())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Invalid shared overseas warehouse binding"); + } + return response.data(); + } + + private record WarehousePalletResponse(WarehousePalletData data) {} + private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) {} + private record ReserveEnvelope(ReserveData data) {} + private record ReserveData(long lockId) {} + private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {} + private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java new file mode 100644 index 000000000..dcd4d7578 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface UnloadPhotoFileGateway { + UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file); + void validate(String customerId, long taskId, long fileId); + DownloadedFile download(String customerId, long taskId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {} + record DownloadedFile(byte[] content, String fileName, String contentType) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java new file mode 100644 index 000000000..138c9a784 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +public interface WarehouseResourceGateway { + WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId); + long reserve(String customerId, long warehouseId, long palletId, long locationId, + String businessType, String businessId, String reservationKey); + void release(String customerId, long resourceLockId); + void scrap(String customerId, long resourceLockId); + void scrapPallet(String customerId, long palletId); + WarehousePalletReference resolvePallet(String palletCode); + default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { + throw new UnsupportedOperationException("Warehouse location resolution is not configured"); + } + default WarehouseLocationReference resolveLocation(long warehouseId, long locationId) { + throw new UnsupportedOperationException("Warehouse location resolution is not configured"); + } + default WarehouseReference requireActiveSharedWarehouse(long warehouseId) { + throw new UnsupportedOperationException("Shared warehouse resolution is not configured"); + } + + record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) { + public WarehousePalletReference(long palletId, String palletNo, long locationId) { + this(palletId, palletNo, 0L, locationId, null, null, 0); + } + } + + record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName, + String status) { } + record WarehouseLocationReference(long locationId, String locationCode, String locationType, + java.math.BigDecimal maxLoadKg, String status) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java new file mode 100644 index 000000000..515ce95e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPalletMergeMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java new file mode 100644 index 000000000..ac27ae96e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InboundPdaOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + InboundPdaOperation findByKey(@Param("customerId") String customerId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java new file mode 100644 index 000000000..b144e7d55 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InboundPdaTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_pda_task WHERE customer_id=#{customerId} AND plan_id=#{planId} LIMIT 1 FOR UPDATE") + InboundPdaTask lockByPlan(@Param("customerId") String customerId, @Param("planId") Long planId); + + @Select("SELECT * FROM ll_inbound_pda_task WHERE customer_id=#{customerId} AND plan_id=#{planId} LIMIT 1") + InboundPdaTask findByPlan(@Param("customerId") String customerId, @Param("planId") Long planId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java new file mode 100644 index 000000000..bcde3f936 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InboundStockReceiptMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_stock_receipt WHERE customer_id=#{customerId} AND task_id=#{taskId} " + + "ORDER BY allocation_id") + List listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java new file mode 100644 index 000000000..ca291794b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReceivingContainerOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_container_operation " + + "WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + ReceivingContainerOperation find(@Param("operatorKey") String operatorKey, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java new file mode 100644 index 000000000..b50e7eabf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReceivingContainerSessionMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey); + + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_operator_key=#{activeOperatorKey} LIMIT 1") + ReceivingContainerSession findActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey); + + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java new file mode 100644 index 000000000..fa28ae74e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; +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 ReceivingSkuReceiptMapper extends BaseMapper { + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + + "AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE") + List lockLineSku(@Param("customerId") String customerId, + @Param("lineId") Long lineId, + @Param("skuCode") String skuCode); + + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + + "AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id") + List listLineSku(@Param("customerId") String customerId, + @Param("lineId") Long lineId, + @Param("skuCode") String skuCode); + + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + + "AND plan_id=#{planId} ORDER BY line_id,pallet_id,sku_code,id") + List listPlan(@Param("customerId") String customerId, + @Param("planId") Long planId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java new file mode 100644 index 000000000..5f8f1f8b3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -0,0 +1,105 @@ +package com.baiyue.lastleg.inboundplan.pda.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPalletMergeMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaOperationMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaTaskMapper; +import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; + +import java.util.List; + +@Repository +public class PdaWorkflowRepository { + @Resource private InboundPlanMapper plans; + @Resource private InboundPlanLineMapper lines; + @Resource private InboundPlanPalletMapper pallets; + @Resource private InboundPlanPalletAllocationMapper allocations; + @Resource private InboundPdaTaskMapper tasks; + @Resource private InboundPdaOperationMapper operations; + @Resource private InboundPalletMergeMapper merges; + @Resource private InboundPlanCartonMapper cartons; + + public InboundPlan lockPlan(String customerId, String planNo) { + return plans.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE")); + } + + public InboundPdaTask lockTask(String customerId, Long planId) { return tasks.lockByPlan(customerId, planId); } + public InboundPdaTask task(String customerId, Long planId) { return tasks.findByPlan(customerId, planId); } + public InboundPdaOperation operation(String customerId, String key) { return operations.findByKey(customerId, key); } + + public InboundPlanPalletAllocation lockAllocation(Long planId, Long allocationId) { + return allocations.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId) + .eq(InboundPlanPalletAllocation::getId, allocationId).last("FOR UPDATE")); + } + + public InboundPlanPalletAllocation lockAllocation(Long planId, Long lineId, Long palletId) { + return allocations.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId).eq(InboundPlanPalletAllocation::getLineId, lineId) + .eq(InboundPlanPalletAllocation::getPalletId, palletId).last("FOR UPDATE")); + } + + public InboundPlanCarton lockCarton(String customerId, String cartonMark) { + return cartons.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark) + .last("FOR UPDATE")); + } + + public List cartons(Long planId) { + return cartons.selectList(new LambdaQueryWrapper().eq(InboundPlanCarton::getPlanId, planId)); + } + public List expectedCartons(Long planId) { return cartons.listExpected(planId); } + public InboundPlanLine lineForCarton(Long planId, InboundPlanCarton carton) { + return cartons.findPlanLine(planId, carton.getSourceInboundDetailId()); + } + public long scannedCartons(Long planId, Long lineId) { return cartons.scannedCount(planId, lineId); } + public boolean hasCartonLabelPool(Long planId) { return cartons.hasLabelPool(planId); } + + public InboundPlanPallet lockPallet(Long planId, Long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, planId).eq(InboundPlanPallet::getId, palletId).last("FOR UPDATE")); + } + + public InboundPlanLine line(Long planId, Long lineId) { + return lines.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, planId).eq(InboundPlanLine::getId, lineId)); + } + + public List lines(Long planId) { + return lines.selectList(new LambdaQueryWrapper().eq(InboundPlanLine::getPlanId, planId)); + } + + public List pallets(Long planId) { + return pallets.selectList(new LambdaQueryWrapper().eq(InboundPlanPallet::getPlanId, planId)); + } + + public List allocations(Long planId) { + return allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId)); + } + + public void insert(InboundPdaTask value) { tasks.insert(value); } + public void insert(InboundPdaOperation value) { operations.insert(value); } + public void insert(InboundPalletMerge value) { merges.insert(value); } + public void insert(InboundPlanPalletAllocation value) { allocations.insert(value); } + public void update(InboundPlan value) { plans.updateById(value); } + public void update(InboundPdaTask value) { tasks.updateById(value); } + public void update(InboundPlanPallet value) { pallets.updateById(value); } + public void update(InboundPlanPalletAllocation value) { allocations.updateById(value); } + public void update(InboundPlanCarton value) { cartons.updateById(value); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java new file mode 100644 index 000000000..dde1adef1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -0,0 +1,952 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.core.type.TypeReference; +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 jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +public class InboundPdaWorkflowService { + private static final long MAX_UNLOAD_PHOTO_SIZE = 10L * 1024 * 1024; + private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000"); + + @Resource private PdaWorkflowRepository repository; + @Resource private WarehouseResourceGateway warehouse; + @Resource private InboundStockReceiptService stockReceipts; + @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; + @Resource private LastlegOrderMapper orders; + @Resource private UnloadPhotoFileGateway unloadPhotoFiles; + @Resource private ObjectMapper sourceObjectMapper; + + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + this.objectMapper = sourceObjectMapper.copy() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + @Transactional + public PdaResponses.Task push(String customerId, String planNo, PdaRequests.Push request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Task.class); + + List pallets = repository.pallets(plan.getId()); + if (pallets.isEmpty() || pallets.stream().anyMatch(this::isNotAssigned)) { + throw conflict("Every planned pallet must have an assigned and locked warehouse pallet/location"); + } + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + LocalDateTime now = LocalDateTime.now(); + if (task == null) { + task = new InboundPdaTask(); + task.setId(IdWorker.getId()); + task.setPlanId(plan.getId()); + task.setCustomerId(customerId); + task.setTaskNo("PDA" + task.getId()); + task.setStatus("PDA_PENDING"); + task.setOperatorId(request.operatorId()); + task.setOperatorName(request.operatorName()); + task.setPushedAt(now); + task.setCreatedAt(now); + task.setUpdatedAt(now); + task.setVersion(0); + repository.insert(task); + } + plan.setStatus("PDA_PENDING"); + plan.setUpdatedAt(now); + repository.update(plan); + PdaResponses.Task result = task(task, planNo); + saveOperation(customerId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public void assign(String customerId, String planNo, long planPalletId, PdaRequests.Assign request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId); + validateDestinationWarehouse(pallet, request.warehouseId()); + if (pallet.getWarehouseResourceLockId() != null) { + if (Objects.equals(pallet.getWarehousePalletId(), request.palletId()) + && Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return; + throw conflict("Planned pallet is already assigned to another warehouse resource"); + } + String destinationKey = destinationKey(pallet); + String businessId = plan.getContainerNo() + ":" + destinationKey; + // A container is unloaded by destination pallet, not by inbound-plan pallet. + // The stable reservation key lets each plan from the same container reuse it. + String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId; + long lockId = warehouse.reserve(customerId, request.warehouseId(), request.palletId(), request.locationId(), + "INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey); + pallet.setWarehousePalletId(request.palletId()); + pallet.setWarehouseLocationId(request.locationId()); + pallet.setWarehouseResourceLockId(lockId); + pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(LocalDateTime.now()); + repository.update(pallet); + List pallets = repository.pallets(plan.getId()); + if (!pallets.isEmpty() && pallets.stream().allMatch(value -> !isNotAssigned(value))) { + plan.setStatus("ASSIGNED"); + plan.setUpdatedAt(LocalDateTime.now()); + repository.update(plan); + } + } + + @Transactional + public PdaResponses.Task task(String customerId, String planNo) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaTask task = repository.task(customerId, plan.getId()); + if (task == null) throw notFound("PDA task not found"); + return task(task, planNo); + } + + @Transactional + public void ensureSignedTask(String customerId, InboundPlan plan, String operatorId, String operatorName, + List photoFileIds, LocalDateTime signedAt) { + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (task == null) { + task = new InboundPdaTask(); + task.setId(IdWorker.getId()); task.setPlanId(plan.getId()); task.setCustomerId(customerId); + task.setTaskNo("PDA" + task.getId()); task.setCreatedAt(signedAt); task.setVersion(0); + repository.insert(task); + } + if ("COMPLETED".equals(task.getStatus())) return; + task.setStatus("SIGNED"); task.setOperatorId(operatorId); task.setOperatorName(operatorName); + task.setUnloadPhotoFileIds(write(photoFileIds)); task.setUnloadPhotoUploadedAt(signedAt); + task.setEstimatedEmptyReturnAt(signedAt.plusDays(2)); task.setUpdatedAt(signedAt); + task.setVersion(value(task.getVersion()) + 1); repository.update(task); + plan.setStatus("SIGNED"); plan.setUpdatedAt(signedAt); repository.update(plan); + } + + @Transactional + public Long assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet, + WarehouseResourceGateway.WarehousePalletReference resource) { + if (pallet.getWarehouseResourceLockId() != null) { + if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return null; + throw conflict("Destination pallet is already bound to another physical pallet"); + } + String destinationKey = destinationKey(pallet); + String businessId = plan.getContainerNo() + ":" + destinationKey; + String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId(); + long lockId = warehouse.reserve(customerId, resource.warehouseId(), resource.palletId(), resource.locationId(), + "INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey); + try { + pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId()); + pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); + return lockId; + } catch (RuntimeException failure) { + compensateReservation(customerId, lockId, failure); + throw failure; + } + } + + public void validateBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line, int packageCount) { + requireActiveTask(customerId, plan); + int received = repository.allocations(plan.getId()).stream() + .filter(value -> Objects.equals(value.getLineId(), line.getId())) + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + int pending = value(line.getRequiredPackageCount()) - received; + if (packageCount <= 0 || packageCount > pending) { + throw conflict("Received package count exceeds the inbound pending total"); + } + } + + public void validateCartonResolved(String customerId, InboundPlan plan, InboundPlanLine line, + InboundPlanPallet pallet, String cartonMark) { + requireActiveTask(customerId, plan); + InboundPlanCarton carton = repository.lockCarton(customerId, cartonMark); + if (carton == null || !("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) { + throw conflict("Carton mark has already been scanned or is unavailable"); + } + InboundPlanLine resolvedLine = carton.getLineId() == null + ? repository.lineForCarton(plan.getId(), carton) : requireLine(plan.getId(), carton.getLineId()); + if (resolvedLine == null || !Objects.equals(resolvedLine.getId(), line.getId())) { + throw conflict("Carton mark belongs to another inbound code or container"); + } + if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match"); + if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) { + throw conflict("This inbound plan line has reached its required carton count"); + } + } + + public void compensateReservation(String customerId, Long resourceLockId, RuntimeException originalFailure) { + if (resourceLockId == null) return; + try { + warehouse.release(customerId, resourceLockId); + } catch (RuntimeException releaseFailure) { + originalFailure.addSuppressed(releaseFailure); + } + } + + @Transactional + public int receiveBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line, + InboundPlanPallet pallet, int packageCount, + String operatorId, String operatorName) { + InboundPdaTask task = requireActiveTask(customerId, plan); + int received = repository.allocations(plan.getId()).stream() + .filter(value -> Objects.equals(value.getLineId(), line.getId())) + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + int pending = value(line.getRequiredPackageCount()) - received; + if (packageCount > pending) throw conflict("Received package count exceeds the inbound pending total"); + InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); + if (allocation == null) { + allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); + allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId()); + allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME); + allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); + allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(ZERO_VOLUME); + allocation.setStatus("RECEIVING"); allocation.setVersion(0); repository.insert(allocation); + } + BigDecimal perPackage = perPackageVolume(line); + allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + packageCount); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + packageCount); + allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()) + .add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus(allocation.getPickedPackageCount() == value(line.getRequiredPackageCount()) + ? "RECEIVED" : "RECEIVING"); bump(allocation); repository.update(allocation); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + packageCount); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()) + .add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP)); + repository.update(pallet); startPicking(task, plan, operatorId, operatorName); + return packageCount; + } + + @Transactional + public PdaResponses.Operation count(String customerId, String planNo, long allocationId, + PdaRequests.PackageOperation request) { + InboundPlan plan = requirePlan(customerId, planNo); + requireLegacyCounting(plan); + InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Operation.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + int picked = value(allocation.getPickedPackageCount()); + if (request.packageCount() < picked) { + throw conflict("Counted package count cannot be less than already picked packages"); + } + if (request.packageCount() > value(allocation.getPlannedPackageCount())) { + throw conflict("Counted package count cannot exceed this pallet allocation"); + } + allocation.setCountedPackageCount(request.packageCount()); + allocation.setStatus(request.packageCount() == value(allocation.getPlannedPackageCount()) + ? "COUNTED" : "COUNTING"); + bump(allocation); + repository.update(allocation); + startPicking(task, plan, request.operatorId(), request.operatorName()); + InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId()); + PdaResponses.Operation result = operation("COUNTED", allocation, line, List.of(), null, null, null); + saveOperation(customerId, task.getId(), allocationId, "COUNT", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public PdaResponses.Operation pick(String customerId, String planNo, long allocationId, + PdaRequests.PackageOperation request) { + InboundPlan plan = requirePlan(customerId, planNo); + requireLegacyCounting(plan); + InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Operation.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId()); + int picked = value(allocation.getPickedPackageCount()); + int countedRemaining = value(allocation.getCountedPackageCount()) - picked; + if (countedRemaining <= 0) throw conflict("Count this pallet allocation before picking"); + int plannedRemaining = value(allocation.getPlannedPackageCount()) - picked; + if (request.packageCount() > plannedRemaining) { + int mergeCount = request.packageCount() - Math.max(0, plannedRemaining); + List candidates = candidates(plan, allocation, mergeCount); + PdaResponses.Operation result = operation("MERGE_CONFIRMATION_REQUIRED", allocation, line, + candidates, plannedRemaining, request.packageCount(), mergeCount); + saveOperation(customerId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result); + return result; + } + if (request.packageCount() > countedRemaining) { + throw conflict("Picked package count cannot exceed the counted remaining packages"); + } + BigDecimal perPackage = perPackageVolume(line); + int newPicked = picked + request.packageCount(); + allocation.setPickedPackageCount(newPicked); + allocation.setActualVolumeCbm(perPackage.multiply(BigDecimal.valueOf(newPicked)).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus(newPicked == value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING"); + bump(allocation); + repository.update(allocation); + InboundPlanPallet pallet = requirePallet(plan.getId(), allocation.getPalletId()); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + request.packageCount()); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add( + perPackage.multiply(BigDecimal.valueOf(request.packageCount()))).setScale(4, RoundingMode.HALF_UP)); + repository.update(pallet); + startPicking(task, plan, request.operatorId(), request.operatorName()); + PdaResponses.Operation result = operation("PICKED", allocation, line, List.of(), null, null, null); + saveOperation(customerId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result); + return result; + } + + /** 海外首次扫描箱唛:仅接受本柜国内冻结且已归属本计划的箱。 */ + @Transactional + public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "SCAN_CARTON", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Carton.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanCarton carton = repository.lockCarton(customerId, request.cartonMark()); + if (carton == null || (carton.getPlanId() != null && !Objects.equals(carton.getPlanId(), plan.getId()))) { + throw notFound("Carton mark is not available to this inbound plan"); + } + if (!("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) { + throw conflict("Carton mark has already been scanned; remove it before scanning again"); + } + InboundPlanPallet pallet = requirePallet(plan.getId(), request.planPalletId()); + if (isNotAssigned(pallet)) throw conflict("Planned pallet must be assigned and locked before carton scanning"); + if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match"); + InboundPlanLine line = carton.getLineId() == null + ? repository.lineForCarton(plan.getId(), carton) + : requireLine(plan.getId(), carton.getLineId()); + if (line == null) throw notFound("Carton mark does not belong to cargo in this inbound plan"); + if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) { + throw conflict("This inbound plan line has reached its required carton count"); + } + InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); + if (allocation == null) { + allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); + allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); + allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME); + allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); + allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation); + } + BigDecimal perPackage = perPackageVolume(line); + carton.setOrderId(line.getOrderId()); carton.setPlanId(plan.getId()); carton.setLineId(line.getId()); + carton.setPlanPalletId(pallet.getId()); carton.setWarehousePalletId(pallet.getWarehousePalletId()); + carton.setWarehouseLocationId(pallet.getWarehouseLocationId()); carton.setPdaTaskId(task.getId()); + carton.setStatus("ON_PALLET"); carton.setBoundAt(LocalDateTime.now()); carton.setUpdatedAt(LocalDateTime.now()); + carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); + allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + 1); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + 1); + allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus(value(allocation.getPickedPackageCount()) >= value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING"); + bump(allocation); repository.update(allocation); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + 1); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP)); + repository.update(pallet); startPicking(task, plan, request.operatorId(), request.operatorName()); + PdaResponses.Carton result = new PdaResponses.Carton("SCANNED", carton.getCartonMark(), pallet.getId(), + allocation.getId(), pallet.getActualPackageCount()); + saveOperation(customerId, task.getId(), allocation.getId(), "SCAN_CARTON", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public PdaResponses.Carton removeCarton(String customerId, String planNo, PdaRequests.RemoveCarton request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "REMOVE_CARTON", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Carton.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanCarton carton = repository.lockCarton(customerId, request.cartonMark()); + if (carton == null || !Objects.equals(carton.getPlanId(), plan.getId())) throw notFound("Carton mark is not expected by this inbound plan"); + if (!"ON_PALLET".equals(carton.getStatus()) || carton.getPlanPalletId() == null) throw conflict("Only a carton currently on a pallet may be removed"); + InboundPlanPallet pallet = requirePallet(plan.getId(), carton.getPlanPalletId()); + InboundPlanLine line = requireLine(plan.getId(), carton.getLineId()); + InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); + if (allocation == null || value(allocation.getPickedPackageCount()) <= 0 || value(pallet.getActualPackageCount()) <= 0) { + throw conflict("Carton pallet allocation is inconsistent"); + } + BigDecimal perPackage = perPackageVolume(line); + allocation.setCountedPackageCount(Math.max(0, value(allocation.getCountedPackageCount()) - 1)); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) - 1); + allocation.setActualVolumeCbm(nonNegative(volume(allocation.getActualVolumeCbm()).subtract(perPackage))); + allocation.setStatus(value(allocation.getPickedPackageCount()) == 0 ? "PENDING_COUNT" : "PICKING"); + bump(allocation); repository.update(allocation); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) - 1); + pallet.setActualVolumeCbm(nonNegative(volume(pallet.getActualVolumeCbm()).subtract(perPackage))); repository.update(pallet); + carton.setOrderId(null); carton.setPlanId(null); carton.setLineId(null); carton.setPlanPalletId(null); + carton.setWarehousePalletId(null); carton.setWarehouseLocationId(null); carton.setPdaTaskId(null); + carton.setStatus("UNASSIGNED"); carton.setBoundAt(null); carton.setVersion(value(carton.getVersion()) + 1); + carton.setUpdatedAt(LocalDateTime.now()); repository.update(carton); + PdaResponses.Carton result = new PdaResponses.Carton("REMOVED", carton.getCartonMark(), pallet.getId(), + allocation.getId(), pallet.getActualPackageCount()); + saveOperation(customerId, task.getId(), allocation.getId(), "REMOVE_CARTON", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public List mergeCandidates(String customerId, String planNo, + long allocationId) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + return candidates(plan, allocation, 1); + } + + @Transactional + public PdaResponses.Merge merge(String customerId, String planNo, long targetAllocationId, + PdaRequests.ConfirmMerge request) { + InboundPlan plan = requirePlan(customerId, planNo); + requireLegacyCounting(plan); + InboundPdaOperation replay = replay(customerId, "MERGE", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Merge.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + if (request.sourceAllocationId() == targetAllocationId) throw conflict("Source and target allocations differ"); + InboundPlanPalletAllocation target = requireAllocation(plan.getId(), targetAllocationId); + InboundPlanPalletAllocation source = requireAllocation(plan.getId(), request.sourceAllocationId()); + InboundPlanPallet targetPallet = requirePallet(plan.getId(), target.getPalletId()); + InboundPlanPallet sourcePallet = requirePallet(plan.getId(), source.getPalletId()); + if (Objects.equals(targetPallet.getId(), sourcePallet.getId())) throw conflict("Source and target pallets differ"); + if (!sameWarehouse(targetPallet, sourcePallet)) throw conflict("Pallet merge requires the same destination warehouse"); + if (value(source.getPickedPackageCount()) < request.packageCount()) { + throw conflict("Source pallet does not contain enough picked packages"); + } + InboundPlanLine sourceLine = requireLine(plan.getId(), source.getLineId()); + InboundPlanLine targetLine = requireLine(plan.getId(), target.getLineId()); + BigDecimal movedVolume = perPackageVolume(sourceLine) + .multiply(BigDecimal.valueOf(request.packageCount())).setScale(4, RoundingMode.HALF_UP); + if (value(sourcePallet.getActualPackageCount()) < request.packageCount()) { + throw conflict("Source pallet actual package count is insufficient"); + } + if (targetPallet.getCapacityCbm() != null + && volume(targetPallet.getActualVolumeCbm()).add(movedVolume).compareTo(targetPallet.getCapacityCbm()) > 0) { + throw conflict("Merged volume exceeds target pallet capacity"); + } + + moveSourceAllocation(plan.getId(), source, targetPallet.getId(), request.packageCount(), movedVolume); + sourcePallet.setActualPackageCount(value(sourcePallet.getActualPackageCount()) - request.packageCount()); + sourcePallet.setActualVolumeCbm(nonNegative(volume(sourcePallet.getActualVolumeCbm()).subtract(movedVolume))); + targetPallet.setActualPackageCount(value(targetPallet.getActualPackageCount()) + request.packageCount()); + targetPallet.setActualVolumeCbm(volume(targetPallet.getActualVolumeCbm()).add(movedVolume)); + boolean scrapped = sourcePallet.getActualPackageCount() == 0; + if (scrapped) { + if (sourcePallet.getWarehouseResourceLockId() == null) { + throw conflict("Source pallet has no warehouse resource lock"); + } + warehouse.scrap(customerId, sourcePallet.getWarehouseResourceLockId()); + sourcePallet.setAssignmentStatus("SCRAPPED"); + sourcePallet.setWarehouseLocationId(null); + sourcePallet.setWarehouseResourceLockId(null); + } + repository.update(sourcePallet); + repository.update(targetPallet); + + String groupNo = "PMG" + IdWorker.getId(); + InboundPalletMerge merge = new InboundPalletMerge(); + merge.setId(IdWorker.getId()); + merge.setCustomerId(customerId); + merge.setTaskId(task.getId()); + merge.setMergeGroupNo(groupNo); + merge.setSourcePalletId(sourcePallet.getId()); + merge.setTargetPalletId(targetPallet.getId()); + merge.setSourceAllocationId(source.getId()); + merge.setTargetAllocationId(target.getId()); + merge.setSourceLineId(sourceLine.getId()); + merge.setTargetLineId(targetLine.getId()); + merge.setSourceOrderId(sourceLine.getOrderId()); + merge.setTargetOrderId(targetLine.getOrderId()); + merge.setMovedPackageCount(request.packageCount()); + merge.setMovedVolumeCbm(movedVolume); + merge.setSourcePalletScrapped(scrapped); + merge.setStatus("CONFIRMED"); + merge.setCreatedAt(LocalDateTime.now()); + repository.insert(merge); + PdaResponses.Merge result = new PdaResponses.Merge("MERGED", groupNo, sourceLine.getOrderId(), + targetLine.getOrderId(), request.packageCount(), movedVolume, scrapped); + saveOperation(customerId, task.getId(), targetAllocationId, "MERGE", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public PdaResponses.Task complete(String customerId, String planNo) { + InboundPlan plan = requirePlan(customerId, planNo); + return completeContainer(customerId, List.of(plan)).get(0); + } + + /** Completes every plan of one physical container and emits exactly one container-level outbox event. */ + @Transactional + public List completeContainer(String customerId, List containerPlans) { + if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans"); + List orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList(); + String containerNo = orderedPlans.get(0).getContainerNo(); + if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan -> + !customerId.equals(plan.getCustomerId()) || !containerNo.equals(plan.getContainerNo()))) { + throw conflict("Inbound plans must belong to one customer and one physical container"); + } + List contexts = orderedPlans.stream().map(plan -> completionContext(customerId, plan)).toList(); + boolean anyCompleted = contexts.stream().anyMatch(context -> "COMPLETED".equals(context.task().getStatus())); + boolean allCompleted = contexts.stream().allMatch(context -> "COMPLETED".equals(context.task().getStatus())); + if (allCompleted) return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); + if (anyCompleted) throw conflict("Container plans are only partially completed; manual reconciliation is required"); + + LocalDateTime now = LocalDateTime.now(); + for (CompletionContext context : contexts) completePlan(customerId, context, now); + String sourceContainerBookingId = sourceContainerBookingId(contexts); + LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt()) + .max(LocalDateTime::compareTo).orElseThrow(); + long aggregateVersion = contexts.stream().map(CompletionContext::task) + .map(InboundPdaTask::getVersion).filter(Objects::nonNull).mapToLong(Integer::longValue).max().orElse(1L); + unloadCompletedOutbox.publishContainerCompleted(customerId, sourceContainerBookingId, containerNo, + now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion); + return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList(); + } + + private String sourceContainerBookingId(List contexts) { + List bookingIds = contexts.stream().map(context -> { + LastlegOrder order = orders.selectById(context.plan().getSourceOrderId()); + if (order == null || order.getSourceContainerBookingId() == null + || order.getSourceContainerBookingId().isBlank()) { + throw new IllegalStateException("Inbound plan has no source container booking ID"); + } + return order.getSourceContainerBookingId(); + }).distinct().toList(); + if (bookingIds.size() != 1) throw conflict("Container plans reference different source container bookings"); + return bookingIds.get(0); + } + + private CompletionContext completionContext(String customerId, InboundPlan plan) { + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (task == null) throw conflict("Push every inbound plan in the container to PDA before completing"); + if ("COMPLETED".equals(task.getStatus())) { + return new CompletionContext(plan, task, List.of(), List.of(), List.of(), List.of()); + } + List unloadPhotoFileIds = unloadPhotoFileIds(task); + if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null + || task.getEstimatedEmptyReturnAt() == null) { + throw conflict("Upload unload photos for every inbound plan before completing the container"); + } + Map pickedByLine = new HashMap<>(); + List allocations = repository.allocations(plan.getId()); + for (InboundPlanPalletAllocation allocation : allocations) { + pickedByLine.merge(allocation.getLineId(), value(allocation.getPickedPackageCount()), Integer::sum); + } + List lines = repository.lines(plan.getId()); + if (lines.isEmpty()) throw conflict("Inbound plan has no lines"); + for (InboundPlanLine line : lines) { + if (pickedByLine.getOrDefault(line.getId(), 0) != value(line.getRequiredPackageCount())) { + throw conflict("All line picked package totals must exactly equal required package totals"); + } + } + Map destinationTypes = lines.stream().collect(java.util.stream.Collectors.toMap( + InboundPlanLine::getId, line -> destinationType(line))); + List cartons = repository.cartons(plan.getId()); + if (cartons.stream().anyMatch(carton -> "OVERSEAS".equals(destinationTypes.get(carton.getLineId())) + && !"ON_PALLET".equals(carton.getStatus()))) { + throw conflict("All expected carton marks must be scanned onto a pallet before stock-in"); + } + return new CompletionContext(plan, task, unloadPhotoFileIds, lines, allocations, cartons); + } + + private void completePlan(String customerId, CompletionContext context, LocalDateTime now) { + InboundPlan plan = context.plan(); + InboundPdaTask task = context.task(); + stockReceipts.stockIn(customerId, plan, task, context.lines(), context.allocations(), + repository.pallets(plan.getId()), now); + for (InboundPlanCarton carton : context.cartons()) { + carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED"); + carton.setReceivedAt(now); carton.setUpdatedAt(now); + carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); + } + task.setStatus("COMPLETED"); + task.setCompletedAt(now); + task.setUpdatedAt(now); + task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("COMPLETED"); + plan.setUpdatedAt(now); + repository.update(plan); + } + + @Transactional + public PdaResponses.Task uploadUnloadPhotos(String customerId, String planNo, PdaRequests.UnloadPhotos request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Task.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + List fileIds = request.fileIds().stream().distinct().sorted().toList(); + if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated"); + fileIds.forEach(fileId -> validateUnloadPhoto(customerId, task.getId(), fileId)); + LocalDateTime uploadedAt = LocalDateTime.now(); + task.setUnloadPhotoFileIds(write(fileIds)); + task.setUnloadPhotoUploadedAt(uploadedAt); + task.setEstimatedEmptyReturnAt(uploadedAt.plusDays(2)); + task.setUpdatedAt(uploadedAt); + task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + PdaResponses.Task result = task(task, planNo); + saveOperation(customerId, task.getId(), null, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request, result); + return result; + } + + public PdaResponses.UnloadPhotoFile uploadUnloadPhotoFile(String customerId, String planNo, + long operatorId, MultipartFile file) { + validateUnloadPhotoFile(file); + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaTask task = requireActiveTask(customerId, plan); + final UnloadPhotoFileGateway.UploadedFile uploaded; + try { + uploaded = unloadPhotoFiles.upload(customerId, task.getId(), operatorId, file); + } catch (IllegalArgumentException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception); + } + return new PdaResponses.UnloadPhotoFile(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + + private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId, + int packageCount, BigDecimal movedVolume) { + source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount); + source.setCountedPackageCount(Math.max(source.getPickedPackageCount(), + value(source.getCountedPackageCount()) - packageCount)); + source.setActualVolumeCbm(nonNegative(volume(source.getActualVolumeCbm()).subtract(movedVolume))); + source.setStatus(source.getPickedPackageCount() == 0 ? "MERGED_OUT" : "PICKED"); + bump(source); + repository.update(source); + + InboundPlanPalletAllocation moved = repository.allocations(planId).stream() + .filter(value -> Objects.equals(value.getLineId(), source.getLineId())) + .filter(value -> Objects.equals(value.getPalletId(), targetPalletId)) + .findFirst().orElse(null); + if (moved == null) { + moved = new InboundPlanPalletAllocation(); + moved.setId(IdWorker.getId()); + moved.setCustomerId(source.getCustomerId()); + moved.setPlanId(planId); + moved.setLineId(source.getLineId()); + moved.setPalletId(targetPalletId); + moved.setAllocatedVolumeCbm(ZERO_VOLUME); + moved.setPlannedPackageCount(0); + moved.setCountedPackageCount(packageCount); + moved.setPickedPackageCount(packageCount); + moved.setActualVolumeCbm(movedVolume); + moved.setStatus("MERGED_IN"); + moved.setVersion(0); + repository.insert(moved); + } else { + moved.setCountedPackageCount(value(moved.getCountedPackageCount()) + packageCount); + moved.setPickedPackageCount(value(moved.getPickedPackageCount()) + packageCount); + moved.setActualVolumeCbm(volume(moved.getActualVolumeCbm()).add(movedVolume)); + moved.setStatus("MERGED_IN"); + bump(moved); + repository.update(moved); + } + } + + private List candidates(InboundPlan plan, + InboundPlanPalletAllocation target, + int needed) { + Map palletById = new HashMap<>(); + for (InboundPlanPallet pallet : repository.pallets(plan.getId())) palletById.put(pallet.getId(), pallet); + Map lineById = new HashMap<>(); + for (InboundPlanLine line : repository.lines(plan.getId())) lineById.put(line.getId(), line); + InboundPlanPallet targetPallet = palletById.get(target.getPalletId()); + if (targetPallet == null) throw notFound("Target pallet not found"); + List result = new ArrayList<>(); + for (InboundPlanPalletAllocation candidate : repository.allocations(plan.getId())) { + if (Objects.equals(candidate.getPalletId(), target.getPalletId())) continue; + int available = value(candidate.getPickedPackageCount()); + if (available <= 0) continue; + InboundPlanPallet pallet = palletById.get(candidate.getPalletId()); + InboundPlanLine line = lineById.get(candidate.getLineId()); + if (pallet == null || line == null || !sameWarehouse(targetPallet, pallet)) continue; + if (targetPallet.getCapacityCbm() != null) { + BigDecimal remainingVolume = targetPallet.getCapacityCbm() + .subtract(volume(targetPallet.getActualVolumeCbm())); + BigDecimal packageVolume = perPackageVolume(line); + if (packageVolume.signum() > 0) { + int capacityPackages = remainingVolume.signum() <= 0 ? 0 + : remainingVolume.divide(packageVolume, 0, RoundingMode.DOWN).intValue(); + available = Math.min(available, capacityPackages); + if (available <= 0) continue; + } + } + result.add(new PdaResponses.MergeCandidate(candidate.getId(), pallet.getId(), pallet.getPalletNo(), + line.getId(), line.getOrderId(), line.getInboundCode(), available, + perPackageVolume(line).multiply(BigDecimal.valueOf(available)).setScale(4, RoundingMode.HALF_UP))); + } + result.sort(Comparator.comparingInt(PdaResponses.MergeCandidate::availablePackageCount).reversed()); + return result; + } + + private boolean sameWarehouse(InboundPlanPallet left, InboundPlanPallet right) { + String leftKey = left.getDestinationWarehouseId() != null + ? left.getDestinationWarehouseId() : left.getDestinationWarehouseCode(); + String rightKey = right.getDestinationWarehouseId() != null + ? right.getDestinationWarehouseId() : right.getDestinationWarehouseCode(); + return leftKey != null && leftKey.equals(rightKey); + } + + private boolean sameWarehouse(InboundPlanCarton carton, InboundPlanPallet pallet) { + String cartonKey = carton.getDestinationWarehouseId() != null + ? carton.getDestinationWarehouseId() : carton.getDestinationWarehouseCode(); + String palletKey = pallet.getDestinationWarehouseId() != null + ? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode(); + return cartonKey != null && cartonKey.equals(palletKey); + } + + private String destinationKey(InboundPlanPallet pallet) { + String value = pallet.getDestinationWarehouseId() != null + ? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode(); + if (value == null || value.isBlank()) throw conflict("Planned pallet destination warehouse is empty"); + return value; + } + + private void validateDestinationWarehouse(InboundPlanPallet pallet, long warehouseId) { + String destinationId = pallet.getDestinationWarehouseId(); + if (destinationId == null || destinationId.isBlank()) return; + try { + if (Long.parseLong(destinationId) != warehouseId) { + throw conflict("Warehouse resource must belong to the planned destination warehouse"); + } + } catch (NumberFormatException exception) { + throw conflict("Planned destination warehouse ID is not a numeric warehouse resource ID"); + } + } + + private void startPicking(InboundPdaTask task, InboundPlan plan, String operatorId, String operatorName) { + LocalDateTime now = LocalDateTime.now(); + if (task.getStartedAt() == null) task.setStartedAt(now); + task.setStatus("PICKING"); + task.setOperatorId(operatorId); + task.setOperatorName(operatorName); + task.setUpdatedAt(now); + task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("PICKING"); + plan.setUpdatedAt(now); + repository.update(plan); + } + + private void requireLegacyCounting(InboundPlan plan) { + if (repository.hasCartonLabelPool(plan.getId())) { + throw conflict("This inbound plan has a carton manifest and must be operated by carton scanning"); + } + } + + private boolean isNotAssigned(InboundPlanPallet pallet) { + return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null + || pallet.getWarehouseResourceLockId() == null + || !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())); + } + + private InboundPlan requirePlan(String customerId, String planNo) { + InboundPlan plan = repository.lockPlan(customerId, planNo); + if (plan == null) throw notFound("Inbound plan not found"); + return plan; + } + + private InboundPdaTask requireActiveTask(String customerId, InboundPlan plan) { + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (task == null) throw conflict("Push the inbound plan to PDA before operating"); + if ("COMPLETED".equals(task.getStatus())) throw conflict("PDA task is already completed"); + return task; + } + + private InboundPlanPalletAllocation requireAllocation(Long planId, Long id) { + InboundPlanPalletAllocation value = repository.lockAllocation(planId, id); + if (value == null) throw notFound("Pallet allocation not found"); + return value; + } + + private InboundPlanPallet requirePallet(Long planId, Long id) { + InboundPlanPallet value = repository.lockPallet(planId, id); + if (value == null) throw notFound("Planned pallet not found"); + return value; + } + + private InboundPlanLine requireLine(Long planId, Long id) { + InboundPlanLine value = repository.line(planId, id); + if (value == null) throw notFound("Inbound plan line not found"); + return value; + } + + private PdaResponses.Operation operation(String result, InboundPlanPalletAllocation allocation, + InboundPlanLine line, + List candidates, + Integer remaining, Integer requested, Integer merge) { + return new PdaResponses.Operation(result, allocation.getId(), value(line.getRequiredPackageCount()), + value(allocation.getCountedPackageCount()), value(allocation.getPickedPackageCount()), + volume(allocation.getActualVolumeCbm()), remaining, requested, merge, candidates); + } + + private PdaResponses.Task task(InboundPdaTask task, String planNo) { + List planPallets = repository.pallets(task.getPlanId()); + List planAllocations = repository.allocations(task.getPlanId()); + Map lines = repository.lines(task.getPlanId()).stream() + .collect(java.util.stream.Collectors.toMap(InboundPlanLine::getId, value -> value)); + List pallets = planPallets.stream().map(value -> new PdaResponses.Pallet( + value.getId(), value.getPalletNo(), value.getDestinationWarehouseId(), + value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), + Objects.requireNonNullElse(value.getWarehousePalletId(), 0L), + Objects.requireNonNullElse(value.getWarehouseLocationId(), 0L), + value.getAssignmentStatus(), InboundPdaWorkflowService.value(value.getActualPackageCount()))).toList(); + List allocations = planAllocations.stream().map(value -> { + InboundPlanLine line = lines.get(value.getLineId()); + if (line == null) throw conflict("Inbound allocation line is missing"); + return new PdaResponses.Allocation(value.getId(), value.getLineId(), value.getPalletId(), + line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(), + InboundPdaWorkflowService.value(line.getRequiredPackageCount()), + InboundPdaWorkflowService.value(value.getPlannedPackageCount()), + InboundPdaWorkflowService.value(value.getCountedPackageCount()), + InboundPdaWorkflowService.value(value.getPickedPackageCount()), value.getStatus(), + InboundPdaWorkflowService.value(value.getVersion())); + }).toList(); + List expectedCartons = repository.expectedCartons(task.getPlanId()).stream() + .map(value -> new PdaResponses.ExpectedCarton(value.getCartonMark(), value.getSkuCode(), + value.getDestinationWarehouseId(), value.getDestinationWarehouseCode(), + recommendedPallet(value, planPallets), value.getStatus())) + .toList(); + return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), + task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), pallets, allocations, + expectedCartons); + } + + private long recommendedPallet(InboundPlanCarton carton, List pallets) { + if (carton.getPlanPalletId() != null) return carton.getPlanPalletId(); + return pallets.stream().filter(value -> Objects.equals(value.getDestinationWarehouseId(), carton.getDestinationWarehouseId()) + || (carton.getDestinationWarehouseId() == null + && Objects.equals(value.getDestinationWarehouseCode(), carton.getDestinationWarehouseCode()))) + .map(InboundPlanPallet::getId).findFirst().orElse(0L); + } + + private List unloadPhotoFileIds(InboundPdaTask task) { + if (task.getUnloadPhotoFileIds() == null || task.getUnloadPhotoFileIds().isBlank()) return List.of(); + try { + return objectMapper.readValue(task.getUnloadPhotoFileIds(), new TypeReference>() {}); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read unload photo file IDs", exception); + } + } + + private void validateUnloadPhoto(String customerId, long taskId, long fileId) { + try { + unloadPhotoFiles.validate(customerId, taskId, fileId); + } catch (IllegalArgumentException exception) { + throw conflict("Unload photo does not exist or does not belong to this PDA task"); + } + } + + private static void validateUnloadPhotoFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot be empty"); + } + if (file.getSize() > MAX_UNLOAD_PHOTO_SIZE) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot exceed 10 MB"); + } + String contentType = file.getContentType(); + if (!"image/jpeg".equalsIgnoreCase(contentType) && !"image/png".equalsIgnoreCase(contentType)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo must be JPG or PNG"); + } + } + + private BigDecimal perPackageVolume(InboundPlanLine line) { + int required = value(line.getRequiredPackageCount()); + if (required <= 0) throw conflict("Inbound line required package count must be positive"); + return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP); + } + + private static String destinationType(InboundPlanLine line) { + if (line.getDestinationType() != null && !line.getDestinationType().isBlank()) { + return line.getDestinationType().trim().toUpperCase(); + } + return line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank() ? "OVERSEAS" : "FBA"; + } + + private InboundPdaOperation replay(String customerId, String action, String key, Object request) { + InboundPdaOperation operation = repository.operation(customerId, key); + if (operation == null) return null; + if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different request"); + } + return operation; + } + + private void saveOperation(String customerId, Long taskId, Long allocationId, String action, + String key, Object request, Object result) { + InboundPdaOperation operation = new InboundPdaOperation(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(customerId); + operation.setTaskId(taskId); + operation.setAllocationId(allocationId); + operation.setAction(action); + operation.setIdempotencyKey(key); + operation.setRequestHash(hash(request)); + operation.setRequestJson(write(request)); + operation.setResultJson(write(result)); + operation.setCreatedAt(LocalDateTime.now()); + repository.insert(operation); + } + + private String hash(Object value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private String write(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize PDA operation", exception); + } + } + + private T read(InboundPdaOperation operation, Class type) { + try { + return objectMapper.readValue(operation.getResultJson(), type); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read persisted PDA operation result", exception); + } + } + + private void bump(InboundPlanPalletAllocation allocation) { + allocation.setVersion(value(allocation.getVersion()) + 1); + } + + private record CompletionContext(InboundPlan plan, InboundPdaTask task, List photoFileIds, + List lines, + List allocations, + List cartons) { } + + private static int value(Integer value) { return value == null ? 0 : value; } + private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; } + private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} 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 new file mode 100644 index 000000000..114d20195 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -0,0 +1,331 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** Converts final PDA package counts into inbound-order/pallet inventory. */ +@Service +public class InboundStockReceiptService { + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper orderDetails; + @Resource private InboundStockReceiptMapper receipts; + @Resource private ReceivingSkuReceiptMapper receivingSkuReceipts; + @Resource private InventoryCommandService inventory; + @Resource private DropshipOrderService dropshipOrders; + @Resource private LastlegOrderStatusHistoryMapper orderHistories; + + @Transactional + public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, + List lines, List allocations, + List pallets, LocalDateTime receivedAt) { + Map orderById = orders.selectBatchIds(lines.stream() + .map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream() + .filter(order -> customerId.equals(order.getCustomerId())) + .collect(Collectors.toMap(LastlegOrder::getId, order -> order)); + Map palletById = pallets.stream() + .collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet)); + Map> allocationsByLine = allocations.stream() + .filter(allocation -> value(allocation.getPickedPackageCount()) > 0) + .sorted(Comparator.comparing(InboundPlanPalletAllocation::getId)) + .collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId)); + Map> skuReceiptsByLine = receivingSkuReceipts + .listPlan(customerId, plan.getId()).stream() + .collect(Collectors.groupingBy(ReceivingSkuReceipt::getLineId)); + + Set expectedAllocationIds = allocationsByLine.values().stream().flatMap(List::stream) + .map(InboundPlanPalletAllocation::getId).collect(Collectors.toSet()); + List existing = receipts.listTask(customerId, task.getId()); + if (!existing.isEmpty()) { + Set receivedAllocationIds = existing.stream().map(InboundStockReceipt::getAllocationId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (receivedAllocationIds.equals(expectedAllocationIds)) { + activateDropship(customerId, task.getId(), orderById, existing); + completeOrders(customerId, plan, orderById, receivedAt); + return; + } + throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying"); + } + + List created = new java.util.ArrayList<>(); + for (InboundPlanLine line : lines) { + LastlegOrder order = orderById.get(line.getOrderId()); + if (order == null) throw conflict("Inbound line tail order is missing or outside the tenant"); + List lineAllocations = allocationsByLine + .getOrDefault(line.getId(), List.of()); + long pickedPackages = lineAllocations.stream() + .mapToLong(allocation -> value(allocation.getPickedPackageCount())).sum(); + if (pickedPackages != value(line.getRequiredPackageCount())) { + throw conflict("PDA picked packages must equal the inbound order package total"); + } + List exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of()); + if (!exactSkuReceipts.isEmpty()) { + created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations, + palletById, order, exactSkuReceipts, pickedPackages, receivedAt)); + continue; + } + List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) + ? List.of() : skuRemainders(order.getId(), pickedPackages); + for (InboundPlanPalletAllocation allocation : lineAllocations) { + InboundPlanPallet pallet = requirePallet(palletById, allocation); + if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + created.add(persist(customerId, plan, task, line, allocation, pallet, order, + SkuSnapshot.unspecified(value(allocation.getPickedPackageCount())), receivedAt)); + } else { + long allocationPackages = value(allocation.getPickedPackageCount()); + for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) { + created.add(persist(customerId, plan, task, line, allocation, pallet, order, sku, receivedAt)); + } + } + } + if (skuRemainders.stream().anyMatch(value -> value.remaining > 0)) { + throw conflict("Inbound SKU package snapshots were not fully received"); + } + } + activateDropship(customerId, task.getId(), orderById, created); + completeOrders(customerId, plan, orderById, receivedAt); + } + + private List persistExactSkuReceipts(String customerId, InboundPlan plan, + InboundPdaTask task, InboundPlanLine line, List lineAllocations, + Map palletById, LastlegOrder order, + List exactReceipts, long pickedPackages, LocalDateTime receivedAt) { + long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum(); + if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total"); + + Map expectedBySku = skuRemainders(order.getId(), pickedPackages).stream() + .collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum, + java.util.LinkedHashMap::new)); + Map actualBySku = exactReceipts.stream().collect(Collectors.groupingBy( + value -> value.getSkuCode().toUpperCase(java.util.Locale.ROOT), java.util.LinkedHashMap::new, + Collectors.summingLong(value -> value(value.getReceivedPackages())))); + Map normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap( + value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum, + java.util.LinkedHashMap::new)); + if (!actualBySku.equals(normalizedExpected)) { + throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots"); + } + + Map allocationByPallet = lineAllocations.stream() + .collect(Collectors.toMap(InboundPlanPalletAllocation::getPalletId, value -> value)); + Map> receiptsByPallet = exactReceipts.stream() + .collect(Collectors.groupingBy(ReceivingSkuReceipt::getPalletId)); + List created = new java.util.ArrayList<>(); + for (Map.Entry> entry : receiptsByPallet.entrySet()) { + InboundPlanPalletAllocation allocation = allocationByPallet.get(entry.getKey()); + if (allocation == null) throw conflict("FBA SKU receipt pallet has no matching allocation"); + long palletPackages = entry.getValue().stream().mapToLong(value -> value(value.getReceivedPackages())).sum(); + if (palletPackages != value(allocation.getPickedPackageCount())) { + throw conflict("FBA SKU receipt pallet total must match the allocation total"); + } + InboundPlanPallet pallet = requirePallet(palletById, allocation); + for (ReceivingSkuReceipt exact : entry.getValue()) { + created.add(persist(customerId, plan, task, line, allocation, pallet, order, + new SkuSnapshot(exact.getSkuCode(), exact.getProductName(), null, + value(exact.getReceivedPackages())), receivedAt)); + } + } + return created; + } + + private void completeOrders(String customerId, InboundPlan plan, Map orderById, + LocalDateTime completedAt) { + for (LastlegOrder snapshot : orderById.values()) { + LastlegOrder order = orders.lockById(customerId, snapshot.getId()); + if (order == null) throw conflict("Inbound tail-leg order disappeared while completing stock-in"); + if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) continue; + String before = order.getCurrentStatus(); + order.setCurrentStatus("INBOUND_COMPLETED"); + order.setSubOrderStatus("INBOUND_PLAN:COMPLETED"); + order.setUpdatedAt(completedAt); + orders.updateById(order); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setCustomerId(order.getCustomerId()); + history.setOrderId(order.getId()); + history.setFromStatus(before); + history.setToStatus("INBOUND_COMPLETED"); + history.setEventId("PDA_INBOUND_COMPLETE:" + plan.getPlanNo()); + history.setRemark("PDA inbound completed and inventory stocked in"); + history.setCreatedAt(completedAt); + orderHistories.insert(history); + } + } + + private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, + InboundPlanPalletAllocation allocation, InboundPlanPallet pallet, + LastlegOrder order, SkuSnapshot sku, LocalDateTime receivedAt) { + long packages = sku.packages(); + if (packages <= 0 || pallet.getWarehousePalletId() == null) { + throw conflict("Stock-in requires picked packages and an assigned warehouse pallet"); + } + String destinationCode = first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()); + if (destinationCode == null && trim(line.getDestinationWarehouseId()) == null) { + throw conflict("Destination warehouse ID or code is required for stock-in"); + } + if (destinationCode == null) destinationCode = ""; + String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode(); + String overseasWarehouseId = required(order.getOverseasWarehouseId(), + "Actual overseas warehouse is required for stock-in"); + InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(), + new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId, + null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode, + line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(), + sku.skuCode(), sku.productNameZh(), sku.productNameEn(), + pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(), + pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(), + order.getSourceEventId(), operationKey, + "PDA inbound completion for " + line.getInboundCode())); + + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(IdWorker.getId()); + receipt.setCustomerId(customerId); + receipt.setPlanId(plan.getId()); + receipt.setTaskId(task.getId()); + receipt.setAllocationId(allocation.getId()); + receipt.setLineId(line.getId()); + receipt.setOrderId(order.getId()); + receipt.setInboundCode(line.getInboundCode()); + receipt.setSkuCode(sku.skuCode()); + receipt.setProductNameZh(sku.productNameZh()); + receipt.setProductNameEn(sku.productNameEn()); + receipt.setWarehousePalletId(pallet.getWarehousePalletId()); + receipt.setPalletNo(pallet.getPalletNo()); + receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); + receipt.setBatchNo(plan.getPlanNo()); + receipt.setReceivedPackages(packages); + receipt.setInventoryBalanceId(result.balanceId()); + receipt.setInventoryPositionId(result.positionId()); + receipt.setInventoryOperationKey(operationKey); + receipt.setReceivedAt(receivedAt); + receipt.setCreatedAt(receivedAt); + receipts.insert(receipt); + return receipt; + } + + private List skuRemainders(Long orderId, long expectedPackages) { + List details = orderDetails.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); + Map grouped = new java.util.LinkedHashMap<>(); + for (LastlegOrderDetail detail : details) { + String skuCode = trim(detail.getSkuCode()); + long packages = value(detail.getPackageCount()); + if (skuCode == null || packages <= 0) continue; + SkuRemainder existing = grouped.get(skuCode); + if (existing == null) { + grouped.put(skuCode, new SkuRemainder(skuCode, trim(detail.getProductNameZh()), + trim(detail.getProductNameEn()), packages)); + } else { + existing.remaining += packages; + } + } + long actualPackages = grouped.values().stream().mapToLong(value -> value.remaining).sum(); + if (grouped.isEmpty() || actualPackages != expectedPackages) { + throw conflict("Inbound SKU package snapshots must exactly match the received package total"); + } + return new java.util.ArrayList<>(grouped.values()); + } + + private static List consume(List remainders, long packages) { + List result = new java.util.ArrayList<>(); + long pending = packages; + for (SkuRemainder remainder : remainders) { + if (pending == 0) break; + if (remainder.remaining == 0) continue; + long accepted = Math.min(pending, remainder.remaining); + result.add(new SkuSnapshot(remainder.skuCode, remainder.productNameZh, + remainder.productNameEn, accepted)); + remainder.remaining -= accepted; + pending -= accepted; + } + if (pending != 0) throw conflict("PDA received packages exceed the frozen SKU package snapshots"); + return result; + } + + private void activateDropship(String customerId, Long taskId, Map orderById, + List stockReceipts) { + Map> receiptsByOrder = stockReceipts.stream() + .filter(receipt -> receipt.getOrderId() != null) + .collect(Collectors.groupingBy(InboundStockReceipt::getOrderId)); + for (LastlegOrder order : orderById.values()) { + if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + dropshipOrders.activateAfterStockIn(customerId, order.getId(), taskId, + receiptsByOrder.getOrDefault(order.getId(), List.of())); + } + } + } + + private static InboundPlanPallet requirePallet(Map pallets, + InboundPlanPalletAllocation allocation) { + InboundPlanPallet pallet = pallets.get(allocation.getPalletId()); + if (pallet == null) throw conflict("PDA allocation pallet is missing"); + return pallet; + } + + private static String required(String value, String message) { + String normalized = trim(value); + if (normalized == null) throw conflict(message); + return normalized; + } + + private static String first(String first, String second) { + String normalized = trim(first); + return normalized == null ? trim(second) : normalized; + } + + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static long value(Integer value) { return value == null ? 0L : value.longValue(); } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + + private static final class SkuRemainder { + private final String skuCode; + private final String productNameZh; + private final String productNameEn; + private long remaining; + + private SkuRemainder(String skuCode, String productNameZh, String productNameEn, long remaining) { + this.skuCode = skuCode; + this.productNameZh = productNameZh; + this.productNameEn = productNameEn; + this.remaining = remaining; + } + } + + private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) { + private static SkuSnapshot unspecified(long packages) { + return new SkuSnapshot("UNSPECIFIED", null, null, packages); + } + } +} 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 new file mode 100644 index 000000000..e9a5e50c0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** 至少一次投递海外卸柜完成事件;国内事件桥按 eventId 幂等接收。 */ +@Component +@Slf4j +public class InboundUnloadCompletedOutboxRelay { + @Resource private UnloadCompletedOutboxRepository repository; + @Resource private EventBridgeClient client; + @Resource private ObjectMapper objectMapper; + @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(); + + @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()); + } + } + } + + 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()); + } + } +} 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 new file mode 100644 index 000000000..535f62079 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -0,0 +1,127 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.EventEnvelope; +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.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; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.dao.DuplicateKeyException; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Objects; + +/** Writes the overseas-to-CN handover to the local transactional outbox. */ +@Service +public class InboundUnloadCompletedOutboxService { + @Resource private LastlegEventOutboxMapper mapper; + @Resource private LastlegOrderMapper orders; + @Resource private ObjectMapper objectMapper; + + /** 兼容单计划入仓完成;多计划同柜必须改调 publishContainerCompleted,并传柜级完成时刻。 */ + public void publish(InboundPlan plan, InboundPdaTask task, List photoFileIds) { + LastlegOrder sourceOrder = orders.selectById(plan.getSourceOrderId()); + if (sourceOrder == null || sourceOrder.getSourceContainerBookingId() == null + || sourceOrder.getSourceContainerBookingId().isBlank()) { + throw new IllegalStateException("Inbound plan has no source container booking ID"); + } + if (task.getCompletedAt() == null) { + throw new IllegalStateException("Inbound task has no completion time"); + } + if (task.getUnloadPhotoUploadedAt() == null) { + throw new IllegalStateException("Inbound task has no unload photo upload time"); + } + publishContainerCompleted(task.getCustomerId(), sourceOrder.getSourceContainerBookingId(), + plan.getContainerNo(), task.getCompletedAt().toInstant(ZoneOffset.UTC), + task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC), + Math.max(1, task.getVersion() == null ? 1 : task.getVersion())); + } + + /** + * 柜级唯一发布入口。调用方只能在同一物理柜的全部计划完成后调用一次;重复调用保留首次冻结事实。 + */ + public void publishContainerCompleted(String customerId, String sourceContainerBookingId, + String containerNo, Instant unloadingCompletedAt, + Instant unloadingPhotoUploadedAt, long aggregateVersion) { + requireText(customerId, "customerId"); + requireText(sourceContainerBookingId, "sourceContainerBookingId"); + requireText(containerNo, "containerNo"); + Objects.requireNonNull(unloadingCompletedAt, "unloadingCompletedAt"); + Objects.requireNonNull(unloadingPhotoUploadedAt, "unloadingPhotoUploadedAt"); + if (unloadingCompletedAt.isBefore(unloadingPhotoUploadedAt)) { + throw new IllegalArgumentException("unloadingCompletedAt cannot be before unloadingPhotoUploadedAt"); + } + + String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + sourceContainerBookingId; + LastlegEventOutbox existing = mapper.findByEventKey(eventKey); + if (existing != null) { + requireSameContainer(existing, customerId, sourceContainerBookingId, containerNo); + return; + } + var payload = new OverseasContainerUnloadedV1(sourceContainerBookingId, + containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt, null); + 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, customerId, "CONTAINER_BOOKING", + sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); + + LastlegEventOutbox event = new LastlegEventOutbox(); + event.setId(IdWorker.getId()); event.setCustomerId(customerId); + event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId()); + event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); + event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING"); + event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + try { + mapper.insert(event); + } catch (DuplicateKeyException exception) { + LastlegEventOutbox raced = mapper.findByEventKey(eventKey); + if (raced == null) throw exception; + requireSameContainer(raced, customerId, sourceContainerBookingId, containerNo); + } + } + + private void requireSameContainer(LastlegEventOutbox existing, String customerId, + String sourceContainerBookingId, String containerNo) { + try { + EventEnvelope envelope = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class); + OverseasContainerUnloadedV1 frozen = objectMapper.treeToValue( + envelope.payload(), OverseasContainerUnloadedV1.class); + if (!Objects.equals(existing.getCustomerId(), customerId) + || !Objects.equals(existing.getAggregateId(), sourceContainerBookingId) + || !Objects.equals(frozen.containerNo(), containerNo)) { + throw new IllegalStateException("Unload-completed event key conflicts with another container"); + } + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read existing unload-completed event", exception); + } + } + + private void requireText(String value, String field) { + if (value == null || value.isBlank()) throw new IllegalArgumentException(field + " is required"); + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize unload-completed event", exception); + } + } +} 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 new file mode 100644 index 000000000..ad9ca9d54 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -0,0 +1,843 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +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.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class ReceivingContainerService { + private static final String PHOTO_DOWNLOAD_PATH = + "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download"; + + @Resource private InboundPlanMapper plans; + @Resource private InboundPlanLineMapper lines; + @Resource private InboundPlanPalletMapper pallets; + @Resource private InboundPlanPalletAllocationMapper allocations; + @Resource private InboundPlanCartonMapper cartons; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper orderDetails; + @Resource private ReceivingContainerSessionMapper sessions; + @Resource private ReceivingContainerOperationMapper operations; + @Resource private ReceivingSkuReceiptMapper skuReceipts; + @Resource private ContainerShippingGateMapper shippingGates; + @Resource private WarehouseResourceGateway warehouse; + @Resource private UnloadPhotoFileGateway unloadPhotos; + @Resource private InboundPdaWorkflowService workflow; + @Resource private ObjectMapper sourceObjectMapper; + + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + objectMapper = sourceObjectMapper.copy() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + public ReceivingContainerResponses.WarehouseProfile warehouseProfile(long overseasWarehouseId) { + WarehouseResourceGateway.WarehouseReference value = warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + return new ReceivingContainerResponses.WarehouseProfile( + Long.toString(value.warehouseId()), value.warehouseCode(), value.warehouseName()); + } + + public List candidates(String operatorKey, long overseasWarehouseId) { + ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + requireSessionWarehouse(current, overseasWarehouseId); + Map> grouped = new LinkedHashMap<>(); + plans.selectList(new LambdaQueryWrapper() + .ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> { + if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) { + grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); + } + }); + return grouped.entrySet().stream().filter(entry -> eligible(entry.getValue(), overseasWarehouseId)).map(entry -> { + requireSingleCustomer(entry.getValue(), entry.getKey()); + Totals totals = totals(entry.getValue()); + boolean active = current != null && entry.getKey().equals(current.getContainerNo()); + return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN", + entry.getValue().size(), totals.inboundCount(), totals.destinationCodes(), totals.plannedPalletCount(), + totals.total(), totals.received(), active); + }).toList(); + } + + public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { + List containerPlans = requirePlans(containerNo); + String customerId = requireSingleCustomer(containerPlans, containerNo); + if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse"); + requireReceivingAvailable(customerId, containerNo); + ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + requireSessionWarehouse(current, overseasWarehouseId); + if (current != null && !containerNo.equals(current.getContainerNo())) { + throw conflict("Current account is locked to another selected container"); + } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( + customerId, operatorKey, receivingWarehouse, containerNo), containerPlans); + } + + @Transactional + public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo, + ReceivingContainerRequests.Action request) { + PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); + ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command); + if (replay != null) { + ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class); + if (!Objects.equals(result.overseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Prepared container belongs to another overseas warehouse"); + } + return result; + } + List containerPlans = requirePlans(containerNo); + String customerId = requireSingleCustomer(containerPlans, containerNo); + if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse"); + requireReceivingAvailable(customerId, containerNo); + String activeOperatorKey = activeOperatorKey(operatorKey); + ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); + requireSessionWarehouse(active, overseasWarehouseId); + if (active != null) { + if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); + ReceivingContainerResponses.Session result = snapshot(active, containerPlans); + save(active, "PREPARE", request.idempotencyKey(), command, result); return result; + } + if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { + throw conflict("Container is already signed or prepared by another account"); + } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse); + LocalDateTime now = LocalDateTime.now(); + ReceivingContainerSession session = new ReceivingContainerSession(); + session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); + session.setOverseasWarehouseId(overseasWarehouseId); + session.setOverseasWarehouseName(receivingWarehouse.warehouseName()); + session.setOperatorKey(operatorKey); session.setOperatorName(operatorName); + session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo)); + session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now); + session.setVersion(0); + try { sessions.insert(session); } + catch (DuplicateKeyException duplicate) { throw conflict("Account or container already has an active receiving session"); } + ReceivingContainerResponses.Session result = snapshot(session, containerPlans); + save(session, "PREPARE", request.idempotencyKey(), command, result); return result; + } + + public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) { + ReceivingContainerSession session = requireCurrent(operatorKey, true); + String customerId = session.getCustomerId(); + if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing"); + UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file); + return new ReceivingContainerResponses.Photo(Long.toString(uploaded.fileId()), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); + } + + @Transactional + public ReceivingContainerResponses.Session sign(String operatorKey, String operatorName, + ReceivingContainerRequests.Sign request) { + ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + ReceivingContainerSession session = requireCurrent(operatorKey, true); + String customerId = session.getCustomerId(); + if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); + List photos = validateSignPhotos(request); + List photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList(); + photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId)); + LocalDateTime now = LocalDateTime.now(); + session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); + session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session); + for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) { + workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photoFileIds, now); + } + ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo())); + save(session, "SIGN", request.idempotencyKey(), request, result); return result; + } + + public ReceivingContainerResponses.Session current(String operatorKey) { + ReceivingContainerSession session = requireCurrent(operatorKey, false); + return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo())); + } + + public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) { + ReceivingContainerSession session = requireCurrent(operatorKey, false); + long parsedFileId = positiveFileId(fileId); + if (signedPhotos(session).stream().noneMatch(photo -> fileId.equals(photo.fileId()))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "Unload photo is not part of the current receiving session"); + } + return unloadPhotos.download(session.getCustomerId(), session.getId(), parsedFileId); + } + + public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) { + requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId); + } + + public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, + ReceivingContainerRequests.CartonResolve request) { + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + String inboundCode = cartonInboundCode(request.cartonMark()); + List matches = refs(customerId, session.getContainerNo()).stream() + .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); + CartonRef carton; + if (matches.size() > 1) { + throw conflict("Carton mark inbound code is ambiguous in the current container"); + } else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) { + carton = new CartonRef(matches.get(0)); + } else if (matches.size() == 1) { + carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + } else { + carton = requireUnmatchedFbaCartonPrefix(customerId, inboundCode); + } + InboundRef ref = carton.ref(); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(), + inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(), + inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), + inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), + inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); + } + + public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, + ReceivingContainerRequests.PalletResolve request) { + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, pallet); + return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), + pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true); + } + + @Transactional + public ReceivingContainerResponses.Receipt receiveBulk(String operatorKey, String operatorName, + ReceivingContainerRequests.BulkReceive request) { + ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); + if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { + throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); + } + List fbaPlanRefs = requireFbaPlanRefs(customerId, session.getContainerNo(), ref); + List skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount()); + skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(), + allocation.ref().line(), allocation.packages())); + WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, resource); + InboundPlanPallet pallet = choosePallet(ref, resource); + Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); + try { + int accepted = 0; + for (FbaSkuAllocation allocation : skuAllocations) { + accepted += workflow.receiveBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), pallet, + allocation.packages(), operatorKey, operatorName); + recordSkuReceipt(session, allocation, pallet); + } + markReceiving(session); + int received = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum(); + int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum(); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted, + received, Math.max(0, total - received), session.getStatus()); + save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); + return result; + } catch (RuntimeException failure) { + workflow.compensateReservation(customerId, resourceLockId, failure); + throw failure; + } + } + + @Transactional + public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName, + ReceivingContainerRequests.CartonReceive request) { + ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + List currentRefs = refs(customerId, session.getContainerNo()); + if (currentRefs.stream().noneMatch(value -> + "OVERSEAS".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA destination must use bulk count"); + } + CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + InboundRef ref = carton.ref(); + if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { + throw conflict("FBA destination must use bulk count"); + } + WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); + validatePallet(ref, resource); + InboundPlanPallet pallet = choosePallet(ref, resource); + workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark()); + Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); + try { + workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton( + request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName)); + markReceiving(session); + ReceivingContainerResponses.Inbound inbound = inbound(ref); + ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", + session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1, + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); + return result; + } catch (RuntimeException failure) { + workflow.compensateReservation(customerId, resourceLockId, failure); + throw failure; + } + } + + @Transactional(isolation = Isolation.READ_COMMITTED) + public ReceivingContainerResponses.Session complete(String operatorKey, + ReceivingContainerRequests.Action request) { + ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); + if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); + List containerPlans = requirePlans(customerId, session.getContainerNo()); + Totals totals = totals(containerPlans); + if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); + validateFbaSkuBalance(customerId, session.getContainerNo()); + workflow.completeContainer(customerId, containerPlans); + LocalDateTime now = LocalDateTime.now(); + session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now); + session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session); + ReceivingContainerResponses.Session result = snapshot(session, containerPlans); + save(session, "COMPLETE", request.idempotencyKey(), request, result); return result; + } + + private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) { + String key = activeOperatorKey(operatorKey); + ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key) + : sessions.findActiveByOperator(key); + if (session == null) throw notFound("Current receiving container session was not found"); + return session; + } + + private ReceivingContainerSession requireSigned(String operatorKey) { + ReceivingContainerSession session = requireCurrent(operatorKey, true); + if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) { + throw conflict("Sign the receiving container before receiving cargo"); + } + return session; + } + + private List requirePlans(String containerNo) { + List values = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + requireSingleCustomer(values, containerNo); + if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { + throw conflict("Receiving container is already completed"); + } + return values; + } + + private List requirePlans(String customerId, String containerNo) { + List values = plansForContainer(customerId, containerNo); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { + throw conflict("Receiving container is already completed"); + } + return values; + } + + private List plansForContainer(String customerId, String containerNo) { + List values = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo) + .orderByAsc(InboundPlan::getId)); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + return values; + } + + private String requireSingleCustomer(List values, String containerNo) { + Set customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim) + .filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new)); + if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { + throw conflict("Receiving container must resolve to exactly one customer: " + containerNo); + } + return customers.iterator().next(); + } + + private List refs(String customerId, String containerNo) { + List result = new ArrayList<>(); + for (InboundPlan plan : plansForContainer(customerId, containerNo)) { + for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) + .orderByAsc(InboundPlanLine::getId))) { + LastlegOrder order = orders.selectById(line.getOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing"); + result.add(new InboundRef(plan, line, order)); + } + } + return result; + } + + private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) { + List matches = refs(customerId, containerNo).stream() + .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); + if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); + Set planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet()); + if (planIds.size() != 1) throw conflict("Inbound code is duplicated inside the current container"); + return matches.stream().min(Comparator.comparing(value -> value.line().getId())).orElseThrow(); + } + + private CartonRef requireCartonRef(String customerId, String containerNo, String cartonMark) { + InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark)); + if (carton == null) throw notFound("Carton mark was not found"); + List matches = refs(customerId, containerNo).stream() + .filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId())) + .filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId())) + .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).toList(); + if (matches.size() != 1) throw conflict("Carton mark belongs to another container"); + return new CartonRef(matches.get(0)); + } + + private String cartonInboundCode(String cartonMark) { + int delimiter = cartonMark == null ? -1 : cartonMark.indexOf('#'); + if (delimiter < 1) throw conflict("Carton mark must contain a non-empty inbound code prefix"); + String inboundCode = cartonMark.substring(0, delimiter).trim(); + if (inboundCode.isEmpty()) throw conflict("Carton mark must contain a non-empty inbound code prefix"); + return inboundCode; + } + + private CartonRef requireUnmatchedFbaCartonPrefix(String customerId, String inboundCode) { + boolean exists = lines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId) + .eq(InboundPlanLine::getInboundCode, inboundCode)).stream().findAny().isPresent(); + if (exists) throw conflict("Carton mark belongs to another container"); + throw notFound("Carton mark was not found"); + } + + private List requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) { + List values = refs(customerId, containerNo).stream() + .filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId())) + .sorted(Comparator.comparing(value -> value.line().getId())).toList(); + if (values.isEmpty() || values.stream().anyMatch(value -> + !"FBA".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA bulk receiving requires every line in the selected inbound plan to be FBA"); + } + Set destinations = values.stream().map(value -> trim(value.line().getFbaWarehouseCode())) + .collect(Collectors.toSet()); + if (destinations.size() != 1 || destinations.contains(null)) { + throw conflict("FBA bulk receiving requires one destination warehouse per inbound plan"); + } + return values; + } + + private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) { + List candidates = pallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream() + .filter(value -> sameDestination(ref.line(), value)) + .filter(value -> value.getWarehousePalletId() == null || Objects.equals(value.getWarehousePalletId(), resource.palletId())) + .sorted(Comparator.comparing((InboundPlanPallet value) -> !Objects.equals(value.getWarehousePalletId(), resource.palletId())) + .thenComparing(value -> value.getActualPackageCount() == null ? 0 : value.getActualPackageCount())) + .toList(); + if (candidates.isEmpty()) throw conflict("Pallet does not match an available destination pallet in this inbound code"); + return candidates.get(0); + } + + private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) { + if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped"); + long actualWarehouse; + try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); } + catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); } + long assignedWarehouse = ref.plan().getOverseasWarehouseId() == null ? actualWarehouse : ref.plan().getOverseasWarehouseId(); + if (pallet.warehouseId() != assignedWarehouse) throw conflict("Pallet belongs to another overseas warehouse"); + String type = destinationType(ref.line(), ref.order()); + if ("FBA".equals(type)) { + if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank() + || !ref.line().getFbaWarehouseCode().equalsIgnoreCase(String.valueOf(pallet.fbaWarehouseCode()))) { + throw conflict("Pallet FBA destination does not match the inbound code"); + } + } else { + if (pallet.fbaWarehouseCode() != null && !pallet.fbaWarehouseCode().isBlank()) { + throw conflict("FBA-designated pallet cannot receive an OVERSEAS destination carton"); + } + } + } + + private boolean sameDestination(InboundPlanLine line, InboundPlanPallet pallet) { + if (line.getDestinationWarehouseId() != null && !line.getDestinationWarehouseId().isBlank()) { + return line.getDestinationWarehouseId().equals(pallet.getDestinationWarehouseId()); + } + return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode()); + } + + private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { + List inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() + .map(this::inbound).toList(); + int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); + int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); + return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(), + session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(), + value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(), + session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds); + } + + private ReceivingContainerResponses.Inbound inbound(InboundRef ref) { + int received = allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream() + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + int total = value(ref.line().getRequiredPackageCount()); + return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), + ref.plan().getOverseasWarehouseId() == null ? ref.order().getOverseasWarehouseId() + : String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(), + ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(), + ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(), + value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received)); + } + + private Totals totals(List containerPlans) { + List values = refs(containerPlans.get(0).getCustomerId(), + containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList(); + List destinationCodes = values.stream().map(this::destinationCode) + .filter(Objects::nonNull).distinct().sorted().toList(); + return new Totals(values.size(), destinationCodes, + values.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(), + values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(), + values.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum()); + } + + private String destinationCode(ReceivingContainerResponses.Inbound inbound) { + return trim("FBA".equals(inbound.destinationType()) ? inbound.fbaWarehouseCode() + : inbound.destinationWarehouseCode()); + } + + private List validateSignPhotos(ReceivingContainerRequests.Sign request) { + if (request.photos() == null || request.photos().size() != 3) { + throw conflict("Exactly three typed unload photos are required"); + } + if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null"); + Set roles = request.photos().stream() + .map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet()); + if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once"); + Set fileIds = request.photos().stream().map(value -> positiveFileId(value.fileId())) + .collect(Collectors.toSet()); + if (fileIds.size() != 3) { + throw conflict("Unload photo file IDs must be positive and unique"); + } + return request.photos().stream() + .sorted(Comparator.comparing(value -> value.role().ordinal())) + .map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId(), null)).toList(); + } + + private long positiveFileId(String value) { + try { + long fileId = Long.parseLong(value); + if (fileId > 0) return fileId; + } catch (NumberFormatException ignored) { + // Validation normally rejects this before the service; keep the domain fail-closed. + } + throw conflict("Unload photo file ID must be a positive decimal string"); + } + + private List allocateFbaSkuReceipts(String customerId, List refs, int packageCount) { + int remaining = packageCount; + List result = new ArrayList<>(); + for (InboundRef ref : refs) { + int linePending = value(ref.line().getRequiredPackageCount()) - receivedLinePackages(ref); + List expectations = skuExpectations(ref); + int skuPending = expectations.stream().mapToInt(expectation -> expectation.expectedPackages() + - receivedSkuPackages(customerId, ref.line().getId(), expectation.skuCode())).sum(); + if (linePending < 0 || skuPending != linePending) { + throw conflict("FBA SKU receipt progress must exactly match the inbound line progress"); + } + for (SkuExpectation expectation : expectations) { + if (remaining == 0) break; + int skuPendingPackages = expectation.expectedPackages() + - receivedSkuPackages(customerId, ref.line().getId(), expectation.skuCode()); + int accepted = Math.min(remaining, skuPendingPackages); + if (accepted > 0) { + result.add(new FbaSkuAllocation(ref, expectation.skuCode(), expectation.productName(), accepted)); + remaining -= accepted; + } + } + if (remaining == 0) break; + } + if (remaining != 0) throw conflict("Received package count exceeds the FBA inbound pending total"); + return result; + } + + private List skuExpectations(InboundRef ref) { + List details = orderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId()) + .eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId)); + Map grouped = new LinkedHashMap<>(); + for (LastlegOrderDetail detail : details) { + String skuCode = trim(detail.getSkuCode()); + int expected = value(detail.getPackageCount()); + if (skuCode == null || expected <= 0) continue; + String key = skuCode.toUpperCase(java.util.Locale.ROOT); + SkuExpectation existing = grouped.get(key); + grouped.put(key, existing == null ? new SkuExpectation(skuCode, + first(detail.getProductNameZh(), detail.getProductNameEn()), expected) + : new SkuExpectation(existing.skuCode(), existing.productName(), existing.expectedPackages() + expected)); + } + List result = List.copyOf(grouped.values()); + int expected = result.stream().mapToInt(SkuExpectation::expectedPackages).sum(); + if (result.isEmpty() || expected != value(ref.line().getRequiredPackageCount())) { + throw conflict("Frozen FBA SKU package snapshots must exactly match the inbound line total"); + } + return result; + } + + private int receivedSkuPackages(String customerId, Long lineId, String skuCode) { + return skuReceipts.listLineSku(customerId, lineId, skuCode).stream() + .mapToInt(value -> value(value.getReceivedPackages())).sum(); + } + + private int receivedLinePackages(InboundRef ref) { + return allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream() + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + } + + private void recordSkuReceipt(ReceivingContainerSession session, FbaSkuAllocation sku, InboundPlanPallet pallet) { + List existing = skuReceipts.lockLineSku(session.getCustomerId(), + sku.ref().line().getId(), sku.skuCode()); + ReceivingSkuReceipt receipt = existing.stream() + .filter(value -> Objects.equals(value.getPalletId(), pallet.getId())).findFirst().orElse(null); + LocalDateTime now = LocalDateTime.now(); + if (receipt == null) { + receipt = new ReceivingSkuReceipt(); receipt.setId(IdWorker.getId()); + receipt.setCustomerId(session.getCustomerId()); receipt.setSessionId(session.getId()); + receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId()); + receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId()); + receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode()); + receipt.setProductName(sku.productName()); receipt.setReceivedPackages(sku.packages()); + receipt.setCreatedAt(now); receipt.setUpdatedAt(now); receipt.setVersion(0); + skuReceipts.insert(receipt); + } else { + receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + sku.packages()); + receipt.setUpdatedAt(now); + if (skuReceipts.updateById(receipt) != 1) throw conflict("SKU receiving progress changed concurrently"); + } + } + + private void validateFbaSkuBalance(String customerId, String containerNo) { + for (InboundRef ref : refs(customerId, containerNo)) { + if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; + List skuCodes = skuExpectations(ref); + int received = 0; + for (SkuExpectation sku : skuCodes) { + int skuReceived = receivedSkuPackages(customerId, ref.line().getId(), sku.skuCode()); + if (skuReceived != sku.expectedPackages()) throw conflict("Every FBA SKU must be fully received before completing the container"); + received += skuReceived; + } + if (received != value(ref.line().getRequiredPackageCount())) { + throw conflict("FBA SKU package snapshots must exactly match the inbound code total"); + } + } + } + + private String destinationType(InboundPlanLine line, LastlegOrder order) { + String value = line.getDestinationType(); + if (value == null || value.isBlank()) value = order.getDestinationType(); + if (value == null || value.isBlank()) value = line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank() + ? "OVERSEAS" : "FBA"; + value = value.trim().toUpperCase(); + if (!List.of("FBA", "OVERSEAS").contains(value)) throw conflict("Unsupported destination type"); + return value; + } + + private void markReceiving(ReceivingContainerSession session) { + if (!"RECEIVING".equals(session.getStatus())) { + session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now()); + } + session.setUpdatedAt(LocalDateTime.now()); update(session); + } + + private void update(ReceivingContainerSession session) { + if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently"); + } + + private ReceivingContainerSession waitingView(String customerId, String operatorKey, + WarehouseResourceGateway.WarehouseReference warehouse, + String containerNo) { + ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId); + value.setOverseasWarehouseId(warehouse.warehouseId()); + value.setOverseasWarehouseName(warehouse.warehouseName()); + value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); + return value; + } + + private boolean eligible(List containerPlans, long overseasWarehouseId) { + String customerId = requireSingleCustomer(containerPlans, containerPlans.get(0).getContainerNo()); + return warehouseEligible(containerPlans, overseasWarehouseId) + && shippingGates.receivingAvailable(customerId, containerPlans.get(0).getContainerNo()) == 1; + } + + private boolean warehouseEligible(List containerPlans, long overseasWarehouseId) { + Set assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream() + .anyMatch(plan -> plan.getOverseasWarehouseId() == null))) { + throw conflict("Container plans have inconsistent receiving warehouse assignments"); + } + return assigned.isEmpty() || assigned.contains(overseasWarehouseId); + } + + private void requireReceivingAvailable(String customerId, String containerNo) { + if (shippingGates.receivingAvailable(customerId, containerNo) != 1) { + throw conflict("Container has not reached PICKUP_READY and is not available for overseas receiving"); + } + } + + private void claimReceivingWarehouse(String customerId, List containerPlans, + WarehouseResourceGateway.WarehouseReference warehouse) { + long overseasWarehouseId = warehouse.warehouseId(); + String warehouseId = String.valueOf(overseasWarehouseId); + for (InboundPlan plan : containerPlans) { + int claimed = plans.claimReceivingWarehouse(customerId, plan.getId(), overseasWarehouseId); + InboundPlan actual = plans.selectById(plan.getId()); + if ((claimed == 0 && actual == null) || actual == null + || !Objects.equals(actual.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Container was claimed by another overseas warehouse"); + } + plan.setOverseasWarehouseId(overseasWarehouseId); + for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + orders.claimReceivingWarehouse(customerId, line.getOrderId(), warehouseId, + warehouse.warehouseName()); + LastlegOrder order = orders.selectById(line.getOrderId()); + if (order == null || !warehouseId.equals(order.getOverseasWarehouseId())) { + throw conflict("Inbound order was claimed by another overseas warehouse"); + } + } + } + } + + private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) { + if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Current container belongs to another overseas warehouse"); + } + } + + private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) { + key = idempotencyKey(key); + ReceivingContainerOperation value = operations.find(operatorKey, key); + if (value == null) return null; + if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) { + throw conflict("Idempotency key was already used with a different request"); + } + return value; + } + + private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) { + key = idempotencyKey(key); + ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId()); + value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId()); + value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key); + value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result)); + value.setCreatedAt(LocalDateTime.now()); operations.insert(value); + } + + private List signPhotos(ReceivingContainerSession session) { + return signedPhotos(session).stream() + .map(photo -> new ReceivingContainerResponses.SignedPhoto(photo.role(), photo.fileId(), + PHOTO_DOWNLOAD_PATH.replace("{fileId}", photo.fileId()))) + .toList(); + } + + private List signedPhotos(ReceivingContainerSession session) { + if (session.getSignPhotos() == null || session.getSignPhotos().isBlank()) return List.of(); + try { return objectMapper.readValue(session.getSignPhotos(), + new TypeReference>() { }); } + catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read signed receiving photos", exception); } + } + + private String hash(Object value) { + try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + + private T read(ReceivingContainerOperation operation, Class type) { + try { return objectMapper.readValue(operation.getResultJson(), type); } + catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + + private static String activeOperatorKey(String operatorKey) { return "OPERATOR:" + operatorKey; } + private static String activeContainerKey(String containerNo) { return "CONTAINER:" + containerNo.trim().toUpperCase(java.util.Locale.ROOT); } + private static String idempotencyKey(String value) { + if (value == null || value.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key is required"); + value = value.trim(); + if (value.length() > 255) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key exceeds 255 characters"); + return value; + } + private static int value(Integer value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + + private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { } + private record CartonRef(InboundRef ref) { } + private record SkuExpectation(String skuCode, String productName, int expectedPackages) { } + private record FbaSkuAllocation(InboundRef ref, String skuCode, String productName, int packages) { } + private record Totals(int inboundCount, List destinationCodes, int plannedPalletCount, + int total, int received) { } + private record PrepareCommand(String containerNo, String idempotencyKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java new file mode 100644 index 000000000..75b3efb7c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -0,0 +1,203 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot; +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentStatus; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +@Service +public class InboundFbaAppointmentService { + @Resource private InboundFbaAppointmentMapper appointmentMapper; + @Resource private InboundFbaAppointmentItemMapper itemMapper; + @Resource private InboundFbaAppointmentQueryMapper queryMapper; + + @Transactional + public FbaAppointmentResponse batchCreate(String customerId, String operatorId, + BatchFbaAppointmentRequest request) { + List lineIds = List.copyOf(request.lineIds()); + if (new HashSet<>(lineIds).size() != lineIds.size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Selected inbound lines contain duplicates"); + } + String appointmentNo = requiredTrimmed(request.appointmentNo(), "Appointment number is required"); + String status = normalizeStatus(request.appointmentStatus()); + String remark = trimToNull(request.appointmentRemark()); + + InboundFbaAppointment existing = find(customerId, appointmentNo); + if (existing != null) { + return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark); + } + + List lines = queryMapper.lockLines(customerId, lineIds); + if (lines.size() != lineIds.size()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "One or more selected inbound lines do not exist in this tenant"); + } + String fbaWarehouseCode = commonFbaWarehouse(lines); + + // The row locks serialize concurrent submissions selecting the same inbound lines. + existing = find(customerId, appointmentNo); + if (existing != null) { + return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark); + } + + LocalDateTime now = LocalDateTime.now(); + InboundFbaAppointment appointment = new InboundFbaAppointment(); + appointment.setId(IdWorker.getId()); + appointment.setCustomerId(customerId); + appointment.setAppointmentNo(appointmentNo); + appointment.setAppointmentAt(request.appointmentAt()); + appointment.setExpectedLoadingAt(request.expectedLoadingAt()); + appointment.setAppointmentStatus(status); + appointment.setAppointmentRemark(remark); + appointment.setFbaWarehouseCode(fbaWarehouseCode); + appointment.setSelectedCount(lines.size()); + appointment.setCreatedBy(trimToNull(operatorId)); + appointment.setCreatedAt(now); + appointment.setUpdatedAt(now); + try { + appointmentMapper.insert(appointment); + } catch (DuplicateKeyException exception) { + InboundFbaAppointment concurrent = find(customerId, appointmentNo); + if (concurrent != null) { + return verifyReplay(concurrent, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark); + } + throw exception; + } + + for (FbaAppointmentLineSnapshot line : lines) { + InboundFbaAppointmentItem item = new InboundFbaAppointmentItem(); + item.setId(IdWorker.getId()); + item.setAppointmentId(appointment.getId()); + item.setPlanId(line.planId()); + item.setLineId(line.lineId()); + item.setOrderId(line.orderId()); + item.setInboundCode(line.inboundCode()); + item.setFbaWarehouseCode(line.fbaWarehouseCode()); + item.setCreatedAt(now); + itemMapper.insert(item); + } + return response(appointment, items(appointment.getId())); + } + + @Transactional(readOnly = true) + public FbaAppointmentResponse detail(String customerId, String appointmentNo) { + InboundFbaAppointment appointment = find(customerId, + requiredTrimmed(appointmentNo, "Appointment number is required")); + if (appointment == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found"); + } + return response(appointment, items(appointment.getId())); + } + + @Transactional + public FbaAppointmentResponse update(String customerId, String appointmentNo, + UpdateFbaAppointmentRequest request) { + InboundFbaAppointment appointment = find(customerId, + requiredTrimmed(appointmentNo, "Appointment number is required")); + if (appointment == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found"); + } + appointment.setAppointmentAt(request.appointmentAt()); + appointment.setExpectedLoadingAt(request.expectedLoadingAt()); + appointment.setAppointmentStatus(normalizeStatus(request.appointmentStatus())); + appointment.setAppointmentRemark(trimToNull(request.appointmentRemark())); + appointment.setUpdatedAt(LocalDateTime.now()); + appointmentMapper.updateById(appointment); + return response(appointment, items(appointment.getId())); + } + + private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List requestedLineIds, + LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, + String status, String remark) { + List existingItems = items(existing.getId()); + Set existingLineIds = existingItems.stream() + .map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet()); + if (!Objects.equals(existing.getAppointmentAt(), appointmentAt) + || !Objects.equals(existing.getExpectedLoadingAt(), expectedLoadingAt) + || !Objects.equals(existing.getAppointmentStatus(), status) + || !Objects.equals(existing.getAppointmentRemark(), remark) + || existingLineIds.size() != requestedLineIds.size() + || !existingLineIds.equals(new HashSet<>(requestedLineIds))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Appointment number already exists with different appointment data"); + } + return response(existing, existingItems); + } + + private String commonFbaWarehouse(List lines) { + List warehouses = lines.stream().map(FbaAppointmentLineSnapshot::fbaWarehouseCode) + .map(InboundFbaAppointmentService::trimToNull).toList(); + if (warehouses.stream().anyMatch(Objects::isNull) || new HashSet<>(warehouses).size() != 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Selected inbound lines must belong to the same non-empty FBA warehouse"); + } + return warehouses.get(0); + } + + private InboundFbaAppointment find(String customerId, String appointmentNo) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(InboundFbaAppointment::getAppointmentNo, appointmentNo); + if (customerId != null) wrapper.eq(InboundFbaAppointment::getCustomerId, customerId); + return appointmentMapper.selectOne(wrapper); + } + + private List items(Long appointmentId) { + return itemMapper.selectList(new LambdaQueryWrapper() + .eq(InboundFbaAppointmentItem::getAppointmentId, appointmentId) + .orderByAsc(InboundFbaAppointmentItem::getLineId)); + } + + private FbaAppointmentResponse response(InboundFbaAppointment appointment, + List items) { + return new FbaAppointmentResponse(appointment.getId(), appointment.getCustomerId(), + appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getExpectedLoadingAt(), + appointment.getAppointmentStatus(), + appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(), + appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(), + items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(), + item.getLineId(), item.getOrderId(), item.getInboundCode(), item.getFbaWarehouseCode())).toList()); + } + + private static String normalizeStatus(String value) { + String normalized = requiredTrimmed(value, "Appointment status is required").toUpperCase(Locale.ROOT); + try { + return FbaAppointmentStatus.valueOf(normalized).name(); + } catch (IllegalArgumentException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Appointment status must be PENDING, SUCCESS, FAILED or CANCELLED"); + } + } + + private static String requiredTrimmed(String value, String message) { + String normalized = trimToNull(value); + if (normalized == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + return normalized; + } + + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} 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 new file mode 100644 index 000000000..ec121e6d7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -0,0 +1,301 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper; +import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import jakarta.annotation.Resource; +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.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +public class InboundPlanService { + @Resource private InboundPlanMapper planMapper; + @Resource private InboundPlanLineMapper lineMapper; + @Resource private InboundPlanPalletMapper palletMapper; + @Resource private InboundPlanPalletAllocationMapper allocationMapper; + @Resource private InboundPlanOrderMapper orderMapper; + @Resource private InboundPlanQueryMapper queryMapper; + @Resource private InventoryBalanceMapper inventoryBalanceMapper; + + @Transactional(readOnly = true) + public PageResult page(String customerId, InboundPlanRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null + && query.createdFrom().isAfter(query.createdTo())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "createdFrom must not be after createdTo"); + } + long total = queryMapper.count(customerId, query); + List records = total == 0 ? List.of() : queryMapper.rows( + customerId, query, (query.page() - 1) * query.size(), query.size()); + long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); + return new PageResult<>(records, query.page(), query.size(), total, pages); + } + + @Transactional + public InboundPlanResponse convert(String customerId, Long selectedOrderId) { + List orders = orderMapper.lockOrder(customerId, selectedOrderId); + if (orders == null || orders.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); + } + String containerNo = trimToNull(orders.get(0).containerNo()); + if (containerNo == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Inbound order container number is empty"); + InboundPlan existing = planMapper.findBySourceOrder(customerId, selectedOrderId); + if (existing != null) { + return detail(customerId, existing); + } + validateOrders(orders); + + LocalDateTime now = LocalDateTime.now(); + InboundPlan plan = new InboundPlan(); + plan.setId(IdWorker.getId()); + plan.setPlanNo("LIP" + plan.getId()); + plan.setCustomerId(customerId); + plan.setSourceOrderId(selectedOrderId); + plan.setContainerNo(containerNo); + plan.setStatus("PENDING_ASSIGNMENT"); + plan.setLineCount(orders.size()); + plan.setTotalVolumeCbm(orders.stream().map(SourceOrderSnapshot::volumeCbm) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + plan.setCreatedAt(now); + plan.setUpdatedAt(now); + + Map orderById = orders.stream() + .collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value)); + Map lineByOrder = createLines(plan, orders); + PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream() + .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), + order.packageCount())) + .toList()); + plan.setPalletCount(allocation.pallets().size()); + planMapper.insert(plan); + + lineByOrder.values().forEach(lineMapper::insert); + Map persistedPallets = createPallets(plan, allocation, orderById); + createAllocations(plan, allocation, lineByOrder, persistedPallets); + + Map palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy( + PalletAllocator.AllocationDraft::orderId, + Collectors.collectingAndThen(Collectors.mapping(item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size))); + for (SourceOrderSnapshot order : orders) { + int count = palletCounts.getOrDefault(order.orderId(), 0); + InboundPlanLine line = lineByOrder.get(order.orderId()); + line.setPlannedPalletCount(count); + lineMapper.updateById(line); + if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) { + throw new IllegalStateException("Failed to update order " + order.orderId()); + } + inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(), + plan.getPlanNo(), now); + orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(), + "Inbound order converted to inbound plan " + plan.getPlanNo(), now); + } + return detail(customerId, plan); + } + + + @Transactional(readOnly = true) + public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(InboundPlan::getPlanNo, planNo); + if (customerId != null) wrapper.eq(InboundPlan::getCustomerId, customerId); + InboundPlan plan = planMapper.selectOne(wrapper); + if (plan == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + } + return detail(customerId, plan); + } + + @Transactional + public InboundPlanResponse.Pallet supplementPallet(String customerId, String planNo, SupplementPalletRequest request) { + InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE")); + if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + if ("COMPLETED".equals(plan.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets"); + List lines = lineMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, plan.getId())); + boolean destinationExists = lines.stream().anyMatch(line -> request.destinationWarehouseId().equals(line.getDestinationWarehouseId())); + if (!destinationExists) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Destination warehouse is not part of this inbound plan"); + int sequence = palletMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, plan.getId())).stream().map(InboundPlanPallet::getSequenceNo) + .filter(Objects::nonNull).max(Integer::compareTo).orElse(0) + 1; + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId()); + pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence)); + pallet.setDestinationWarehouseId(request.destinationWarehouseId()); pallet.setDestinationWarehouseCode(request.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(request.destinationWarehouseName()); pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); + pallet.setAllocatedVolumeCbm(BigDecimal.ZERO); pallet.setSequenceNo(sequence); pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet); + plan.setPalletCount(plan.getPalletCount() + 1); plan.setUpdatedAt(LocalDateTime.now()); planMapper.updateById(plan); + return new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), pallet.getDestinationWarehouseId(), + pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), pallet.getCapacityCbm(), + pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getAssignmentStatus(), + null, pallet.getActualVolumeCbm(), pallet.getActualPackageCount()); + } + + private Map createLines(InboundPlan plan, List orders) { + Map result = new HashMap<>(); + for (SourceOrderSnapshot order : orders) { + InboundPlanLine line = new InboundPlanLine(); + line.setId(IdWorker.getId()); + line.setCustomerId(order.customerId()); + line.setPlanId(plan.getId()); + line.setOrderId(order.orderId()); + line.setInboundCode(order.inboundCode()); + line.setDestinationType(order.fbaWarehouseCode() == null || order.fbaWarehouseCode().isBlank() + ? "OVERSEAS" : "FBA"); + line.setFbaWarehouseCode(order.fbaWarehouseCode()); + line.setDestinationWarehouseId(order.destinationWarehouseId()); + line.setDestinationWarehouseCode(order.destinationWarehouseCode()); + line.setDestinationWarehouseName(order.destinationWarehouseName()); + line.setVolumeCbm(volume(order)); + line.setRequiredPackageCount(order.packageCount()); + line.setPlannedPalletCount(0); + result.put(order.orderId(), line); + } + return result; + } + + private Map createPallets( + InboundPlan plan, PalletAllocator.Result allocation, Map orderById) { + Map warehouseSamples = orderById.values().stream() + .collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left)); + Map result = new HashMap<>(); + for (PalletAllocator.PalletDraft draft : allocation.pallets()) { + SourceOrderSnapshot sample = warehouseSamples.get(draft.warehouseKey()); + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); + pallet.setCustomerId(plan.getCustomerId()); + pallet.setPlanId(plan.getId()); + pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", result.size() + 1)); + pallet.setDestinationWarehouseId(sample.destinationWarehouseId()); + pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(sample.destinationWarehouseName()); + pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); + pallet.setAllocatedVolumeCbm(draft.allocatedVolumeCbm()); + pallet.setSequenceNo(result.size() + 1); + pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); + pallet.setActualPackageCount(0); + palletMapper.insert(pallet); + result.put(draft, pallet); + } + return result; + } + + private void createAllocations(InboundPlan plan, PalletAllocator.Result allocation, + Map lineByOrder, + Map pallets) { + for (PalletAllocator.AllocationDraft draft : allocation.allocations()) { + InboundPlanPalletAllocation item = new InboundPlanPalletAllocation(); + item.setId(IdWorker.getId()); + item.setCustomerId(plan.getCustomerId()); + item.setPlanId(plan.getId()); + item.setLineId(lineByOrder.get(draft.orderId()).getId()); + item.setPalletId(pallets.get(draft.pallet()).getId()); + item.setAllocatedVolumeCbm(draft.allocatedVolumeCbm()); + item.setPlannedPackageCount(draft.plannedPackageCount()); + item.setCountedPackageCount(0); + item.setPickedPackageCount(0); + item.setActualVolumeCbm(BigDecimal.ZERO); + item.setStatus("PENDING_COUNT"); + item.setVersion(0); + allocationMapper.insert(item); + } + } + + private void validateOrders(List orders) { + for (SourceOrderSnapshot order : orders) { + if (!("PENDING_ASSIGNMENT".equals(order.currentStatus()) + || "DESTINATION_ASSIGNED".equals(order.currentStatus()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code " + order.inboundCode() + " is not ready for an inbound plan"); + } + if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + + " must have one destination warehouse consistent with all cargo details"); + } + if (order.inboundOrderNo() != null && !order.inboundOrderNo().isBlank()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code " + order.inboundCode() + " already belongs to an inbound plan"); + } + if (volume(order).signum() < 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + " has negative volume"); + } + if (order.packageCount() < 0 || (order.packageCount() > 0 && volume(order).signum() == 0)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + " has invalid package count or volume"); + } + } + } + + private InboundPlanResponse detail(String customerId, InboundPlan plan) { + if (customerId != null && !customerId.equals(plan.getCustomerId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + } + List lines = lineMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId)); + List pallets = palletMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, plan.getId()).orderByAsc(InboundPlanPallet::getSequenceNo)); + List allocations = allocationMapper.selectList( + new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, plan.getId()) + .orderByAsc(InboundPlanPalletAllocation::getId)); + return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(), + plan.getContainerNo(), + plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(), + lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(), + line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(), + line.getVolumeCbm(), line.getRequiredPackageCount(), line.getPlannedPalletCount())).toList(), + pallets.stream().map(pallet -> new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), + pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), + pallet.getCapacityCbm(), pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), + pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(), + pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(), + pallet.getActualPackageCount())).toList(), + allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(), + item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(), + item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList()); + } + + private BigDecimal volume(SourceOrderSnapshot order) { + return Objects.requireNonNullElse(order.volumeCbm(), BigDecimal.ZERO); + } + + private String warehouseKey(SourceOrderSnapshot order) { + String id = trimToNull(order.destinationWarehouseId()); + String code = trimToNull(order.destinationWarehouseCode()); + return id != null ? "ID:" + id : code == null ? null : "CODE:" + code; + } + + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java new file mode 100644 index 000000000..74f6f0af9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.inboundplan.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class PalletAllocator { + public static final BigDecimal PALLET_CAPACITY_CBM = new BigDecimal("2.0000"); + + private PalletAllocator() { + } + + public static Result allocate(List lines) { + Map> groups = new LinkedHashMap<>(); + for (LineInput line : lines) { + if (line.volumeCbm() == null || line.volumeCbm().signum() < 0) { + throw new IllegalArgumentException("Volume must be zero or positive"); + } + if (line.packageCount() < 0) { + throw new IllegalArgumentException("Package count must be zero or positive"); + } + if (line.volumeCbm().signum() == 0 && line.packageCount() > 0) { + throw new IllegalArgumentException("Positive package count requires positive volume"); + } + groups.computeIfAbsent(line.warehouseKey(), ignored -> new ArrayList<>()).add(line); + } + + List pallets = new ArrayList<>(); + List allocations = new ArrayList<>(); + for (List group : groups.values()) { + PalletDraft current = null; + int sequence = 0; + for (LineInput line : group) { + BigDecimal remaining = line.volumeCbm(); + while (remaining.signum() > 0) { + if (current == null || current.remainingCapacity().signum() == 0) { + current = new PalletDraft(line.warehouseKey(), ++sequence, BigDecimal.ZERO); + pallets.add(current); + } + BigDecimal allocated = remaining.min(current.remainingCapacity()); + current.add(allocated); + allocations.add(new AllocationDraft(line.orderId(), current, allocated, 0)); + remaining = remaining.subtract(allocated); + } + } + } + return new Result(List.copyOf(pallets), distributePackages(lines, allocations)); + } + + private static List distributePackages(List lines, + List allocations) { + Map linesByOrder = new LinkedHashMap<>(); + for (LineInput line : lines) { + if (linesByOrder.put(line.orderId(), line) != null) { + throw new IllegalArgumentException("Duplicate order id " + line.orderId()); + } + } + Map> allocationsByOrder = new LinkedHashMap<>(); + for (AllocationDraft allocation : allocations) { + allocationsByOrder.computeIfAbsent(allocation.orderId(), ignored -> new ArrayList<>()).add(allocation); + } + + List result = new ArrayList<>(allocations.size()); + Map replacements = new LinkedHashMap<>(); + for (LineInput line : lines) { + List orderAllocations = allocationsByOrder.getOrDefault(line.orderId(), List.of()); + int assigned = 0; + for (int index = 0; index < orderAllocations.size(); index++) { + AllocationDraft allocation = orderAllocations.get(index); + int packages = index == orderAllocations.size() - 1 + ? line.packageCount() - assigned + : allocation.allocatedVolumeCbm().multiply(BigDecimal.valueOf(line.packageCount())) + .divide(line.volumeCbm(), 0, RoundingMode.DOWN).intValueExact(); + assigned += packages; + replacements.put(allocation, new AllocationDraft(allocation.orderId(), allocation.pallet(), + allocation.allocatedVolumeCbm(), packages)); + } + } + for (AllocationDraft allocation : allocations) { + result.add(replacements.get(allocation)); + } + return List.copyOf(result); + } + + public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) { + } + + public static final class PalletDraft { + private final String warehouseKey; + private final int sequenceNo; + private BigDecimal allocatedVolumeCbm; + + private PalletDraft(String warehouseKey, int sequenceNo, BigDecimal allocatedVolumeCbm) { + this.warehouseKey = warehouseKey; + this.sequenceNo = sequenceNo; + this.allocatedVolumeCbm = allocatedVolumeCbm; + } + + private void add(BigDecimal volume) { + allocatedVolumeCbm = allocatedVolumeCbm.add(volume); + } + + public String warehouseKey() { + return warehouseKey; + } + + public int sequenceNo() { + return sequenceNo; + } + + public BigDecimal allocatedVolumeCbm() { + return allocatedVolumeCbm; + } + + public BigDecimal remainingCapacity() { + return PALLET_CAPACITY_CBM.subtract(allocatedVolumeCbm); + } + } + + public record AllocationDraft(Long orderId, PalletDraft pallet, BigDecimal allocatedVolumeCbm, + int plannedPackageCount) { + } + + public record Result(List pallets, List allocations) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java new file mode 100644 index 000000000..c3cd7e45f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.mapper") +public class InventoryMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java new file mode 100644 index 000000000..953a3ceb7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory.command; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.command.mapper") +public class InventoryCommandMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java new file mode 100644 index 000000000..20f92ab33 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.inventory.command.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inventory") +@Tag(name = "Tail-leg inbound-order package inventory commands") +public class InventoryCommandController { + public static final String MANAGE_PERMISSION = OverseasPermissions.INVENTORY_EDIT; + public static final String ADJUST_PERMISSION = OverseasPermissions.INVENTORY_ADJUST; + @Resource private InventoryCommandService service; + + @PostMapping("/hold") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')") + @Operation(summary = "Move available inbound-order packages to hold", description = "Required authority: " + OverseasPermissions.INVENTORY_EDIT) + public ApiResponse hold(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + requirePermission(user, MANAGE_PERMISSION); + requirePlatform(user); + return ApiResponse.success(service.holdPlatform(operator(user), request)); + } + + @PostMapping("/release") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')") + @Operation(summary = "Release held inbound-order packages", description = "Required authority: " + OverseasPermissions.INVENTORY_EDIT) + public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + requirePermission(user, MANAGE_PERMISSION); + requirePlatform(user); + return ApiResponse.success(service.releasePlatform(operator(user), request)); + } + + @PostMapping("/adjust") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_ADJUST + "')") + @Operation(summary = "Adjust inbound-order package inventory after a physical count", description = "Required authority: " + OverseasPermissions.INVENTORY_ADJUST) + public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Adjustment request) { + requirePermission(user, ADJUST_PERMISSION); + requirePlatform(user); + return ApiResponse.success(service.adjustPlatform(operator(user), request)); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); + } + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private void requirePermission(AuthenticatedUser user, String permission) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java new file mode 100644 index 000000000..2773296fa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.inventory.command.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +public final class InventoryCommandRequests { + private InventoryCommandRequests() { + } + + public record StockIn( + String customerId, + String customerName, + @NotBlank String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + @NotNull Long inboundOrderId, + @NotBlank String inboundOrderNo, + @NotBlank String inboundCode, + String skuCode, + String productNameZh, + String productNameEn, + @NotNull Long palletId, + @NotBlank String palletNo, + String batchNo, + Long warehouseLocationId, + String warehouseLocationCode, + @Min(0) long packages, + String businessOrderNo, + String sourceEventId, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } + + public record InTransit( + String customerName, + @NotBlank String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + @NotNull Long inboundOrderId, + @NotBlank String inboundOrderNo, + @NotBlank String inboundCode, + String skuCode, + String productNameZh, + String productNameEn, + @Positive long packages, + String businessOrderNo, + String sourceEventId, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } + + public record Move( + @NotNull Long positionId, + @Positive long packages, + String businessOrderNo, + String outboundOrderNo, + String sourceEventId, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } + + public record Adjustment( + @NotNull Long positionId, + long packageDelta, + @NotBlank String reason, + String businessOrderNo, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java new file mode 100644 index 000000000..944c38358 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inventory.command.dto; + +public record InventoryCommandResponse( + Long operationId, + String operationType, + Long balanceId, + Long positionId, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + boolean palletEmpty, + boolean replayed +) { + public InventoryCommandResponse asReplay() { + return new InventoryCommandResponse(operationId, operationType, balanceId, positionId, + availablePackages, holdPackages, allocatedPackages, pickedPackages, palletEmpty, true); + } +} 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 new file mode 100644 index 000000000..0fd568123 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inventory.command.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InventoryCommandMapper { + @Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} LIMIT 1") + InventoryPosition findPosition(@Param("positionId") Long positionId); + + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND id=#{positionId} LIMIT 1 FOR UPDATE") + InventoryPosition lockPosition(@Param("customerId") String customerId, + @Param("positionId") Long positionId); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1 FOR UPDATE") + InventoryBalance lockBalance(@Param("customerId") String customerId, + @Param("balanceId") Long balanceId); + + @Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " + + "FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}") + long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId); +} 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 new file mode 100644 index 000000000..f42015986 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -0,0 +1,549 @@ +package com.baiyue.lastleg.inventory.command.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryOperationMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +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.LocalDateTime; +import java.util.HexFormat; + +@Service +public class InventoryCommandService { + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryLedgerMapper ledgers; + @Resource private InventoryOperationMapper operations; + @Resource private InventoryCommandMapper commands; + @Resource private WarehouseResourceGateway warehouseGateway; + @Resource private ObjectMapper objectMapper; + + @Transactional + public InventoryCommandResponse registerInTransit(String customerId, String operatorId, + InventoryCommandRequests.InTransit request) { + if (request.packages() <= 0) throw badRequest("In-transit packages must be positive"); + return execute(customerId, operatorId, "IN_TRANSIT", request.idempotencyKey(), request, operation -> { + InventoryBalance balance = balance(customerId, request); + long before = value(balance.getInTransitPackages()); + balance.setInTransitPackages(before + request.packages()); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, operation.getId(), balance, null, sku(request.skuCode()), "IN_TRANSIT", + "OUTSIDE", "IN_TRANSIT", before, request.packages(), before + request.packages(), + request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(), + request.idempotencyKey(), request.remark()); + return response(operation, balance, null, false); + }); + } + + @Transactional + public InventoryCommandResponse stockIn(String customerId, String operatorId, + InventoryCommandRequests.StockIn request) { + return execute(customerId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> { + InventoryBalance balance = balance(customerId, request); + InventoryPosition position = position(customerId, balance, request); + long before = value(position.getAvailablePackages()); + position.setAvailablePackages(before + request.packages()); + position.setStatus("OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + long transferred = Math.min(value(balance.getInTransitPackages()), request.packages()); + balance.setInTransitPackages(value(balance.getInTransitPackages()) - transferred); + balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages()); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, operation.getId(), balance, position, sku(request.skuCode()), "STOCK_IN", + transferred == request.packages() ? "IN_TRANSIT" : "OUTSIDE", "AVAILABLE", + before, request.packages(), before + request.packages(), + request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(), + request.idempotencyKey(), request.remark()); + return response(operation, balance, position, false); + }); + } + + @Transactional + public InventoryCommandResponse hold(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request); + } + + @Transactional + public InventoryCommandResponse holdPlatform(String operatorId, InventoryCommandRequests.Move request) { + return move(customerForPosition(request.positionId()), operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request); + } + + @Transactional + public InventoryCommandResponse release(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request); + } + + @Transactional + public InventoryCommandResponse releasePlatform(String operatorId, InventoryCommandRequests.Move request) { + return move(customerForPosition(request.positionId()), operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request); + } + + @Transactional + public InventoryCommandResponse allocate(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request); + } + + @Transactional + public InventoryCommandResponse unallocate(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "UNALLOCATE", Bucket.ALLOCATED, Bucket.AVAILABLE, request); + } + + @Transactional + public InventoryCommandResponse pick(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, request); + } + + @Transactional + public InventoryCommandResponse ship(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return execute(customerId, operatorId, "SHIP", request.idempotencyKey(), request, operation -> { + Locked locked = locked(customerId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + requireAtLeast(value(position.getPickedPackages()), request.packages(), "picked"); + requireAtLeast(value(balance.getPickedPackages()), request.packages(), "balance picked"); + long before = value(position.getPickedPackages()); + position.setPickedPackages(before - request.packages()); + position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages()); + touch(balance); + balances.updateById(balance); + boolean palletEmpty = commands.palletPackages(customerId, position.getPalletId()) == 0; + if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId()); + ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "SHIP", + "PICKED", "SHIPPED", before, -request.packages(), before - request.packages(), + request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), + request.idempotencyKey(), request.remark()); + return response(operation, balance, position, palletEmpty); + }); + } + + @Transactional + public InventoryCommandResponse adjust(String customerId, String operatorId, + InventoryCommandRequests.Adjustment request) { + if (request.packageDelta() == 0) { + throw badRequest("Adjustment package delta cannot be zero"); + } + return execute(customerId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> { + Locked locked = locked(customerId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + long before = value(position.getAvailablePackages()); + long after = before + request.packageDelta(); + long balanceAfter = value(balance.getAvailablePackages()) + request.packageDelta(); + if (after < 0 || balanceAfter < 0) { + throw conflict("Adjustment would make inventory negative"); + } + position.setAvailablePackages(after); + position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + balance.setAvailablePackages(balanceAfter); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "ADJUST", + "AVAILABLE", "AVAILABLE", before, request.packageDelta(), after, + request.businessOrderNo(), null, null, null, request.idempotencyKey(), + request.reason() + remarkSuffix(request.remark())); + return response(operation, balance, position, false); + }); + } + + @Transactional + public InventoryCommandResponse adjustPlatform(String operatorId, InventoryCommandRequests.Adjustment request) { + return adjust(customerForPosition(request.positionId()), operatorId, request); + } + + 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 -> { + Locked locked = locked(customerId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + requireAtLeast(packages(position, from), request.packages(), from.name().toLowerCase()); + requireAtLeast(packages(balance, from), request.packages(), "balance " + from.name().toLowerCase()); + long beforeTarget = packages(position, to); + packages(position, from, packages(position, from) - request.packages()); + packages(position, to, beforeTarget + request.packages()); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + packages(balance, from, packages(balance, from) - request.packages()); + packages(balance, to, packages(balance, to) + request.packages()); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), operationType, + from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(), + request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), + request.idempotencyKey(), request.remark()); + return response(operation, balance, position, false); + }); + } + + private InventoryBalance balance(String customerId, InventoryCommandRequests.StockIn request) { + return balance(customerId, request.customerName(), request.overseasWarehouseId(), + request.overseasWarehouseCode(), request.overseasWarehouseName(), request.destinationWarehouseId(), + request.destinationWarehouseCode(), request.destinationWarehouseName(), request.inboundOrderId(), + request.inboundOrderNo(), request.inboundCode(), request.skuCode(), request.productNameZh(), + request.productNameEn()); + } + + private InventoryBalance balance(String customerId, InventoryCommandRequests.InTransit request) { + return balance(customerId, request.customerName(), request.overseasWarehouseId(), + request.overseasWarehouseCode(), request.overseasWarehouseName(), request.destinationWarehouseId(), + request.destinationWarehouseCode(), request.destinationWarehouseName(), request.inboundOrderId(), + request.inboundOrderNo(), request.inboundCode(), request.skuCode(), request.productNameZh(), + request.productNameEn()); + } + + private InventoryBalance balance(String customerId, String customerName, String overseasWarehouseId, + String overseasWarehouseCode, String overseasWarehouseName, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, Long inboundOrderId, String inboundOrderNo, + String inboundCode, String requestedSkuCode, String productNameZh, + String productNameEn) { + String skuCode = sku(requestedSkuCode); + String destinationId = trim(destinationWarehouseId); + String destinationCode = trim(destinationWarehouseCode); + if (destinationId == null && destinationCode == null) { + throw badRequest("Destination warehouse ID or code is required"); + } + if (destinationCode == null) destinationCode = ""; + InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId, + destinationCode, inboundOrderId, inboundCode); + if (balance != null) { + mergeSkuSnapshot(balance, skuCode); + return balance; + } + balance = new InventoryBalance(); + balance.setId(IdWorker.getId()); + balance.setCustomerId(customerId); + balance.setCustomerName(trim(customerName)); + balance.setOverseasWarehouseId(overseasWarehouseId.trim()); + balance.setOverseasWarehouseCode(trim(overseasWarehouseCode)); + balance.setOverseasWarehouseName(trim(overseasWarehouseName)); + balance.setDestinationWarehouseId(destinationId); + balance.setDestinationWarehouseCode(destinationCode); + balance.setDestinationWarehouseName(trim(destinationWarehouseName)); + balance.setInboundOrderId(inboundOrderId); + balance.setInboundOrderNo(inboundOrderNo.trim()); + balance.setInboundCode(inboundCode.trim()); + balance.setSkuCode(skuCode); + balance.setProductNameZh(trim(productNameZh)); + balance.setProductNameEn(trim(productNameEn)); + zero(balance); + balance.setVersion(0L); + balance.setCreatedAt(LocalDateTime.now()); + balance.setUpdatedAt(balance.getCreatedAt()); + try { + balances.insert(balance); + return balance; + } catch (DuplicateKeyException exception) { + InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId, + destinationCode, inboundOrderId, inboundCode); + if (concurrent != null) { + mergeSkuSnapshot(concurrent, skuCode); + return concurrent; + } + throw exception; + } + } + + private InventoryPosition position(String customerId, InventoryBalance balance, + InventoryCommandRequests.StockIn request) { + String batchNo = trim(request.batchNo()) == null ? "DEFAULT" : request.batchNo().trim(); + InventoryPosition position = positions.lockPosition(customerId, balance.getId(), request.palletId(), batchNo); + if (position != null) return position; + position = new InventoryPosition(); + position.setId(IdWorker.getId()); + position.setCustomerId(customerId); + position.setBalanceId(balance.getId()); + position.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + position.setPalletId(request.palletId()); + position.setPalletNo(request.palletNo().trim()); + position.setBatchNo(batchNo); + position.setWarehouseLocationId(request.warehouseLocationId()); + String locationCode = trim(request.warehouseLocationCode()); + position.setWarehouseLocationCode(locationCode == null + ? "LOC-" + defaultCode(balance) + "-DEFAULT" : locationCode); + position.setAvailablePackages(0L); + position.setHoldPackages(0L); + position.setAllocatedPackages(0L); + position.setPickedPackages(0L); + position.setReceivingPackages(0L); + position.setStatus("OCCUPIED"); + position.setVersion(0L); + position.setCreatedAt(LocalDateTime.now()); + position.setUpdatedAt(position.getCreatedAt()); + try { + positions.insert(position); + return position; + } catch (DuplicateKeyException exception) { + InventoryPosition concurrent = positions.lockPosition(customerId, balance.getId(), request.palletId(), batchNo); + if (concurrent != null) return concurrent; + throw exception; + } + } + + private Locked locked(String customerId, Long positionId) { + InventoryPosition position = commands.lockPosition(customerId, positionId); + if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found"); + InventoryBalance balance = commands.lockBalance(customerId, position.getBalanceId()); + if (balance == null) throw new IllegalStateException("Inventory position balance is missing"); + return new Locked(balance, position); + } + + private String customerForPosition(Long positionId) { + InventoryPosition position = commands.findPosition(positionId); + if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found"); + return position.getCustomerId(); + } + + private InventoryCommandResponse execute(String customerId, String operatorId, String operationType, + String key, Object request, Work work) { + String normalizedKey = required(key, "Idempotency key is required"); + String requestHash = hash(request); + InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey); + if (existing != null) return replay(existing, operationType, requestHash); + + InventoryOperation operation = new InventoryOperation(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(customerId); + operation.setIdempotencyKey(normalizedKey); + operation.setOperationType(operationType); + operation.setRequestHash(requestHash); + operation.setStatus("PROCESSING"); + operation.setCreatedAt(LocalDateTime.now()); + operation.setUpdatedAt(operation.getCreatedAt()); + try { + operations.insert(operation); + } catch (DuplicateKeyException exception) { + InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey); + if (concurrent != null) return replay(concurrent, operationType, requestHash); + throw exception; + } + + InventoryCommandResponse response = work.run(operation); + operation.setStatus("COMPLETED"); + operation.setResultJson(write(response)); + operation.setUpdatedAt(LocalDateTime.now()); + operations.updateById(operation); + return response; + } + + private InventoryCommandResponse replay(InventoryOperation operation, String type, String requestHash) { + if (!type.equals(operation.getOperationType()) || !requestHash.equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different inventory request"); + } + if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) { + throw conflict("Inventory operation is still processing"); + } + try { + return objectMapper.readValue(operation.getResultJson(), InventoryCommandResponse.class).asReplay(); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read inventory idempotency result", exception); + } + } + + private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance, + InventoryPosition position, String skuCode, String businessType, String from, String to, + long beforePackages, long deltaPackages, long afterPackages, + String businessOrderNo, String inboundOrderNo, String outboundOrderNo, + String sourceEventId, String idempotencyKey, String remark) { + InventoryLedger ledger = new InventoryLedger(); + ledger.setId(IdWorker.getId()); + ledger.setCustomerId(customerId); + ledger.setBalanceId(balance.getId()); + ledger.setPositionId(position == null ? null : position.getId()); + ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + ledger.setInboundOrderId(balance.getInboundOrderId()); + ledger.setPalletId(position == null ? null : position.getPalletId()); + ledger.setPalletNo(position == null ? null : position.getPalletNo()); + ledger.setBatchNo(position == null ? null : position.getBatchNo()); + ledger.setWarehouseLocationCode(position == null ? null : position.getWarehouseLocationCode()); + ledger.setSkuCode(skuCode); + ledger.setBusinessType(businessType); + ledger.setBusinessOrderId(balance.getInboundOrderId()); + ledger.setBusinessOrderNo(trim(businessOrderNo)); + ledger.setInboundCode(balance.getInboundCode()); + ledger.setInboundOrderNo(trim(inboundOrderNo) == null + ? balance.getInboundOrderNo() : trim(inboundOrderNo)); + ledger.setOutboundOrderNo(trim(outboundOrderNo)); + ledger.setSourceEventId(trim(sourceEventId)); + ledger.setIdempotencyKey(idempotencyKey + ":" + operationId); + ledger.setStatusFrom(from); + ledger.setStatusTo(to); + ledger.setBeforePackages(beforePackages); + ledger.setDeltaPackages(deltaPackages); + ledger.setAfterPackages(afterPackages); + ledger.setBeforeQuantity(0L); + ledger.setDeltaQuantity(0L); + ledger.setAfterQuantity(0L); + ledger.setOperatorId(trim(operatorId)); + ledger.setRemark(trim(remark)); + ledger.setOccurredAt(LocalDateTime.now()); + ledger.setCreatedAt(ledger.getOccurredAt()); + ledgers.insert(ledger); + } + + private static void mergeSkuSnapshot(InventoryBalance balance, String skuCode) { + String existing = trim(balance.getSkuCode()); + if (existing == null) { + balance.setSkuCode(skuCode); + } else if (!existing.equals(skuCode) && !"MIXED".equals(existing)) { + balance.setSkuCode("MIXED"); + balance.setProductNameZh(null); + balance.setProductNameEn(null); + } + } + + private InventoryCommandResponse response(InventoryOperation operation, InventoryBalance balance, + InventoryPosition position, boolean palletEmpty) { + return new InventoryCommandResponse(operation.getId(), operation.getOperationType(), balance.getId(), + position == null ? null : position.getId(), value(balance.getAvailablePackages()), value(balance.getHoldPackages()), + value(balance.getAllocatedPackages()), value(balance.getPickedPackages()), palletEmpty, false); + } + + private static void touch(InventoryBalance balance) { + balance.setVersion(value(balance.getVersion()) + 1); + balance.setUpdatedAt(LocalDateTime.now()); + } + + private static void zero(InventoryBalance balance) { + balance.setAvailablePackages(0L); balance.setHoldPackages(0L); balance.setAllocatedPackages(0L); + balance.setPickedPackages(0L); balance.setInTransitPackages(0L); balance.setReceivingPackages(0L); + balance.setProcurementPackages(0L); + } + + private static long positionPackages(InventoryPosition position) { + return value(position.getAvailablePackages()) + value(position.getHoldPackages()) + + value(position.getAllocatedPackages()) + value(position.getPickedPackages()) + + value(position.getReceivingPackages()); + } + + private static long packages(InventoryPosition position, Bucket bucket) { + return switch (bucket) { + case AVAILABLE -> value(position.getAvailablePackages()); + case HOLD -> value(position.getHoldPackages()); + case ALLOCATED -> value(position.getAllocatedPackages()); + case PICKED -> value(position.getPickedPackages()); + }; + } + + private static void packages(InventoryPosition position, Bucket bucket, long value) { + switch (bucket) { + case AVAILABLE -> position.setAvailablePackages(value); + case HOLD -> position.setHoldPackages(value); + case ALLOCATED -> position.setAllocatedPackages(value); + case PICKED -> position.setPickedPackages(value); + } + } + + private static long packages(InventoryBalance balance, Bucket bucket) { + return switch (bucket) { + case AVAILABLE -> value(balance.getAvailablePackages()); + case HOLD -> value(balance.getHoldPackages()); + case ALLOCATED -> value(balance.getAllocatedPackages()); + case PICKED -> value(balance.getPickedPackages()); + }; + } + + private static void packages(InventoryBalance balance, Bucket bucket, long value) { + switch (bucket) { + case AVAILABLE -> balance.setAvailablePackages(value); + case HOLD -> balance.setHoldPackages(value); + case ALLOCATED -> balance.setAllocatedPackages(value); + case PICKED -> balance.setPickedPackages(value); + } + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private String write(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize inventory operation", exception); + } + } + + private static String defaultCode(InventoryBalance balance) { + String code = trim(balance.getOverseasWarehouseCode()); + return code == null ? balance.getOverseasWarehouseId() : code; + } + + private static void requireAtLeast(long actual, long requested, String field) { + if (actual < requested) throw conflict("Insufficient " + field + " packages"); + } + + private static String required(String value, String message) { + String normalized = trim(value); + if (normalized == null) throw badRequest(message); + return normalized; + } + + private static String trim(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static String sku(String value) { + String normalized = trim(value); + return normalized == null ? "UNSPECIFIED" : normalized; + } + + private static String remarkSuffix(String remark) { + String value = trim(remark); + return value == null ? "" : "; " + value; + } + + private static long value(Long value) { return value == null ? 0L : value; } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + private static ResponseStatusException badRequest(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + + private enum Bucket { AVAILABLE, HOLD, ALLOCATED, PICKED } + private record Locked(InventoryBalance balance, InventoryPosition position) {} + @FunctionalInterface private interface Work { InventoryCommandResponse run(InventoryOperation operation); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java new file mode 100644 index 000000000..e2468fcaa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.inventory.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; + +/** Real-time package balance for one inbound order at one overseas warehouse. */ +@Data +@TableName("ll_inventory_balance") +public class InventoryBalance { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String customerName; + private String overseasWarehouseId; + private String overseasWarehouseCode; + private String overseasWarehouseName; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private Long inboundOrderId; + private String inboundOrderNo; + private String inboundCode; + private String skuCode; + private String productNameZh; + private String productNameEn; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java new file mode 100644 index 000000000..4ffeff2cd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.inventory.entity; + +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 lombok.Data; + +import java.time.LocalDateTime; + +/** Append-only inventory fact. Never update or delete this entity. */ +@Data +@TableName("ll_inventory_ledger") +public class InventoryLedger { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long balanceId; + private Long positionId; + private String overseasWarehouseId; + private String destinationWarehouseCode; + private Long inboundOrderId; + private Long palletId; + private String palletNo; + private String batchNo; + private String skuCode; + private String cartonMark; + private Long cartonPackages; + private String warehouseLocationCode; + private String businessType; + private Long businessOrderId; + private String businessOrderNo; + private String inboundCode; + private String inboundOrderNo; + private String outboundOrderNo; + private String sourceEventId; + private String idempotencyKey; + private String statusFrom; + private String statusTo; + private Long beforePackages; + private Long deltaPackages; + private Long afterPackages; + /** Quantity snapshot is retained even though current PDA operations are carton-count based. */ + @TableField(exist = false) + private Long beforeQuantity; + @TableField(exist = false) + private Long deltaQuantity; + @TableField(exist = false) + private Long afterQuantity; + private String operatorId; + private String remark; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java new file mode 100644 index 000000000..43317683d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inventory.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; + +/** Command-level idempotency record for inventory mutations. */ +@Data +@TableName("ll_inventory_operation") +public class InventoryOperation { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String idempotencyKey; + private String operationType; + private String requestHash; + private String status; + private String resultJson; + private String lastError; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java new file mode 100644 index 000000000..3e5b276ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.inventory.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; + +/** Physical stock snapshot. A location is currently informational only. */ +@Data +@TableName("ll_inventory_position") +public class InventoryPosition { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long balanceId; + private String overseasWarehouseId; + private String destinationWarehouseCode; + private Long palletId; + private String palletNo; + private String batchNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long receivingPackages; + private String status; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java new file mode 100644 index 000000000..9fddae72c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.model.InventorySummary; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +public interface InventoryBalanceMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{overseasWarehouseId} " + + "AND destination_warehouse_code=#{destinationWarehouseCode} " + + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1") + InventoryBalance findDimension(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{overseasWarehouseId} " + + "AND destination_warehouse_code=#{destinationWarehouseCode} " + + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1 FOR UPDATE") + InventoryBalance lockDimension(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode); + + @Update("UPDATE ll_inventory_balance SET inbound_order_no=#{planNo},updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}") + int bindInboundPlan(@Param("customerId") String customerId, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode, + @Param("planNo") String planNo, + @Param("updatedAt") LocalDateTime updatedAt); + + @Select("") + List listTenant(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("inboundCode") String inboundCode, + @Param("offset") long offset, + @Param("limit") int limit); + + @Select("") + InventorySummary summarize(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId); +} 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 new file mode 100644 index 000000000..f95b777ba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; + +public interface InventoryLedgerMapper extends BaseMapper { + @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); + + @Select("") + List listTenant(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("inboundCode") String inboundCode, + @Param("businessType") String businessType, + @Param("businessOrderNo") String businessOrderNo, + @Param("startedAt") LocalDateTime startedAt, + @Param("endedAt") LocalDateTime endedAt, + @Param("offset") long offset, + @Param("limit") int limit); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java new file mode 100644 index 000000000..3b6a16229 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +public interface InventoryOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_operation WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1") + InventoryOperation findIdempotency(@Param("customerId") String customerId, + @Param("idempotencyKey") String idempotencyKey); + + @Select("SELECT * FROM ll_inventory_operation WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1 FOR UPDATE") + InventoryOperation lockIdempotency(@Param("customerId") String customerId, + @Param("idempotencyKey") String idempotencyKey); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java new file mode 100644 index 000000000..59b438680 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InventoryPositionMapper extends BaseMapper { + @Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " + + "WHERE b.customer_id=#{customerId} AND p.balance_id=#{balanceId} ORDER BY p.pallet_no,p.batch_no") + List listBalance(@Param("customerId") String customerId, + @Param("balanceId") Long balanceId); + + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " + + "ORDER BY balance_id,batch_no") + List listPallet(@Param("customerId") String customerId, + @Param("palletId") Long palletId); + + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " + + "AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE") + InventoryPosition lockPosition(@Param("customerId") String customerId, + @Param("balanceId") Long balanceId, + @Param("palletId") Long palletId, + @Param("batchNo") String batchNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java new file mode 100644 index 000000000..39b7585cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inventory.model; + +import lombok.Data; + +@Data +public class InventorySummary { + private Long balanceCount; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java new file mode 100644 index 000000000..049e9f827 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory.query; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.query.mapper") +public class InventoryQueryMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java new file mode 100644 index 000000000..87226951e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -0,0 +1,117 @@ +package com.baiyue.lastleg.inventory.query.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView; +import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; +import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView; +import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.service.InventoryQueryService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@Tag(name = "Inventory queries") +public class InventoryQueryController { + private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory"; + + @Resource private InventoryQueryService service; + + @GetMapping(BASE_PATH + "/summary") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") + @Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) + public ApiResponse summary( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return ApiResponse.success(service.dashboard(platformScope(user, customerId), query)); + } + + @GetMapping(BASE_PATH + "/balances") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") + @Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) + public ApiResponse> balances( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return ApiResponse.success(service.balances(platformScope(user, customerId), query)); + } + + @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") + @Operation(summary = "List pallet and batch positions for one inbound-order balance", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) + public ApiResponse> positions( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long balanceId, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.PositionQuery query) { + return ApiResponse.success(service.positions(platformScope(user, customerId), balanceId, query)); + } + + @GetMapping(BASE_PATH + "/ledger") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") + @Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) + public ApiResponse> ledgers( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.LedgerQuery query) { + return ApiResponse.success(service.ledgers(platformScope(user, customerId), query)); + } + + @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')") + @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) + public ResponseEntity exportBalances( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv"); + } + + @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')") + @Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) + public ResponseEntity exportLedgers( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.LedgerQuery query) { + return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv"); + } + + private static ResponseEntity csv(byte[] body, String filename) { + return ResponseEntity.ok() + .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) + .body(body); + } + + private String platformScope(AuthenticatedUser user, String requestedCustomerId) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); + } + return requestedCustomerId == null || requestedCustomerId.isBlank() || "0".equals(requestedCustomerId.trim()) + ? null : requestedCustomerId.trim(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java new file mode 100644 index 000000000..d8e25d096 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryBalanceView( + Long id, + String customerId, + String customerName, + String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, + String skuCode, + String productNameZh, + String productNameEn, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long inTransitPackages, + long receivingPackages, + long procurementPackages, + LocalDateTime updatedAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java new file mode 100644 index 000000000..d73cd23af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inventory.query.dto; + +public record InventoryDashboardResponse( + long inboundOrderCount, + long palletCount, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long inTransitPackages, + long receivingPackages, + long procurementPackages +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java new file mode 100644 index 000000000..7f60e2f0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryLedgerView( + Long id, + String customerId, + String overseasWarehouseId, + String destinationWarehouseCode, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, + String palletNo, + String batchNo, + String warehouseLocationCode, + String skuCode, + String businessType, + String businessOrderNo, + String outboundOrderNo, + String statusFrom, + String statusTo, + long beforePackages, + long deltaPackages, + long afterPackages, + String operatorId, + String remark, + LocalDateTime occurredAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java new file mode 100644 index 000000000..8f67d79d5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryPositionView( + Long id, + Long balanceId, + String customerId, + String overseasWarehouseId, + String destinationWarehouseCode, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, + Long palletId, + String palletNo, + String batchNo, + Long warehouseLocationId, + String warehouseLocationCode, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long receivingPackages, + String status, + LocalDateTime updatedAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java new file mode 100644 index 000000000..de7467004 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +public final class InventoryQueryRequests { + private InventoryQueryRequests() { + } + + public record BalanceQuery( + @Min(1) @Schema(defaultValue = "1", minimum = "1") Long page, + @Min(1) @Max(200) @Schema(defaultValue = "20", minimum = "1", maximum = "200") Long size, + String customerName, + String overseasWarehouseId, + String overseasWarehouseCode, + String destinationWarehouseCode, + String inboundOrderNo, + String inboundCode, + String businessOrderNo, + String skuCode, + String productName, + String batchNo, + String warehouseLocationCode, + String inventoryStatus + ) { + public BalanceQuery { + if (page == null || page == 0) page = 1L; + if (size == null || size == 0) size = 20L; + } + } + + public record PositionQuery(String palletNo, String batchNo, String inventoryStatus) { + } + + public record LedgerQuery( + @Min(1) @Schema(defaultValue = "1", minimum = "1") Long page, + @Min(1) @Max(200) @Schema(defaultValue = "20", minimum = "1", maximum = "200") Long size, + String overseasWarehouseId, + String destinationWarehouseCode, + String businessType, + String businessOrderNo, + String inboundOrderNo, + String inboundCode, + String outboundOrderNo, + String palletNo, + String batchNo, + String skuCode, + String warehouseLocationCode, + LocalDateTime occurredFrom, + LocalDateTime occurredTo + ) { + public LedgerQuery { + if (page == null || page == 0) page = 1L; + if (size == null || size == 0) size = 20L; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java new file mode 100644 index 000000000..16c85a001 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -0,0 +1,133 @@ +package com.baiyue.lastleg.inventory.query.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InventoryQueryMapper { + String BALANCE_FILTER = " WHERE 1=1" + + " AND customer_id=#{customerId}" + + " AND customer_name LIKE CONCAT('%',#{q.customerName},'%')" + + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND overseas_warehouse_code=#{q.overseasWarehouseCode}" + + " AND destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=ll_inventory_balance.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + + " AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')" + + " AND (product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR product_name_en LIKE CONCAT('%',#{q.productName},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + + "" + + " AND (available_packages+allocated_packages+picked_packages) > 0" + + " AND hold_packages > 0" + + " AND allocated_packages > 0" + + " AND picked_packages > 0" + + " AND in_transit_packages > 0" + + " AND receiving_packages > 0" + + " AND procurement_packages > 0" + + ""; + + String JOINED_BALANCE_FILTER = " WHERE 1=1" + + " AND b.customer_id=#{customerId}" + + " AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')" + + " AND b.overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND b.overseas_warehouse_code=#{q.overseasWarehouseCode}" + + " AND b.destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=b.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + + " AND b.sku_code LIKE CONCAT('%',#{q.skuCode},'%')" + + " AND (b.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR b.product_name_en LIKE CONCAT('%',#{q.productName},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + + "" + + " AND (b.available_packages+b.allocated_packages+b.picked_packages) > 0" + + " AND b.hold_packages > 0" + + " AND b.allocated_packages > 0" + + " AND b.picked_packages > 0" + + " AND b.in_transit_packages > 0" + + " AND b.receiving_packages > 0" + + " AND b.procurement_packages > 0" + + ""; + + @Select("") + List balances(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countBalances(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + InventoryAggregateProjection aggregate(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + long countPallets(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + List positions(@Param("customerId") String customerId, + @Param("balanceId") long balanceId, + @Param("q") InventoryQueryRequests.PositionQuery query); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1") + InventoryBalance ownedBalance(@Param("customerId") String customerId, @Param("balanceId") long balanceId); + + @Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} LIMIT 1") + InventoryBalance findBalance(@Param("balanceId") long balanceId); + + String LEDGER_FILTER = " WHERE 1=1" + + " AND customer_id=#{customerId}" + + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND business_type=#{q.businessType}" + + " AND business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%')" + + " AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND outbound_order_no LIKE CONCAT('%',#{q.outboundOrderNo},'%')" + + " AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')" + + " AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')" + + " AND occurred_at >=#{q.occurredFrom}" + + " AND occurred_at <=#{q.occurredTo}"; + + @Select("") + List ledgers(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.LedgerQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countLedgers(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.LedgerQuery query); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java new file mode 100644 index 000000000..066d81207 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inventory.query.model; + +import lombok.Data; + +@Data +public class InventoryAggregateProjection { + private Long dimensionCount; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java new file mode 100644 index 000000000..eb6bcab9a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -0,0 +1,188 @@ +package com.baiyue.lastleg.inventory.query.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView; +import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; +import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView; +import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper; +import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Service +public class InventoryQueryService { + private static final int EXPORT_LIMIT = 10_000; + private static final Set BALANCE_STATUSES = Set.of( + "AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "IN_TRANSIT", "RECEIVING", "PROCUREMENT"); + private static final Set POSITION_STATUSES = Set.of( + "AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "RECEIVING"); + + @Resource private InventoryQueryMapper mapper; + + public InventoryDashboardResponse dashboard(String customerId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + InventoryAggregateProjection value = mapper.aggregate(customerId, query); + long availablePackages = number(value.getAvailablePackages()); + long holdPackages = number(value.getHoldPackages()); + long allocatedPackages = number(value.getAllocatedPackages()); + long pickedPackages = number(value.getPickedPackages()); + long inTransitPackages = number(value.getInTransitPackages()); + long receivingPackages = number(value.getReceivingPackages()); + long procurementPackages = number(value.getProcurementPackages()); + long totalPackages = availablePackages + holdPackages + allocatedPackages + pickedPackages; + return new InventoryDashboardResponse(number(value.getDimensionCount()), mapper.countPallets(customerId, query), + totalPackages, Math.max(0, totalPackages - holdPackages), holdPackages, allocatedPackages, + pickedPackages, inTransitPackages, + receivingPackages, procurementPackages); + } + + public PageResult balances(String customerId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + long total = mapper.countBalances(customerId, query); + List records = total == 0 ? List.of() : mapper.balances( + customerId, query, offset(query.page(), query.size()), query.size()).stream().map(this::balanceView).toList(); + return page(records, query.page(), query.size(), total); + } + + public List positions(String customerId, long balanceId, + InventoryQueryRequests.PositionQuery query) { + validateStatus(query.inventoryStatus(), POSITION_STATUSES); + InventoryBalance balance = customerId == null + ? mapper.findBalance(balanceId) + : mapper.ownedBalance(customerId, balanceId); + if (balance == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found"); + } + String recordCustomerId = customerId == null ? balance.getCustomerId() : customerId; + return mapper.positions(recordCustomerId, balanceId, query).stream() + .map(value -> positionView(value, balance)).toList(); + } + + public PageResult ledgers(String customerId, InventoryQueryRequests.LedgerQuery query) { + validateDateRange(query); + long total = mapper.countLedgers(customerId, query); + List records = total == 0 ? List.of() : mapper.ledgers( + customerId, query, offset(query.page(), query.size()), query.size()).stream().map(this::ledgerView).toList(); + return page(records, query.page(), query.size(), total); + } + + public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + List rows = new ArrayList<>(); + rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," + + "destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages," + + "holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages"); + mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add( + String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()), + csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()), + csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()), + csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), + csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), + csv(v.procurementPackages())))); + return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); + } + + public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) { + validateDateRange(query); + List rows = new ArrayList<>(); + rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," + + "inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," + + "beforePackages,deltaPackages,afterPackages,operatorId,remark"); + mapper.ledgers(customerId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add( + String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), + csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), + csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), + csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), + csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())))); + return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); + } + + private InventoryBalanceView balanceView(InventoryBalance value) { + long availablePackages = number(value.getAvailablePackages()); + long holdPackages = number(value.getHoldPackages()); + long allocatedPackages = number(value.getAllocatedPackages()); + long pickedPackages = number(value.getPickedPackages()); + long inTransitPackages = number(value.getInTransitPackages()); + long receivingPackages = number(value.getReceivingPackages()); + long procurementPackages = number(value.getProcurementPackages()); + long totalPackages = availablePackages + holdPackages + allocatedPackages + pickedPackages; + return new InventoryBalanceView(value.getId(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(), + value.getOverseasWarehouseCode(), value.getOverseasWarehouseName(), value.getDestinationWarehouseId(), + value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getInboundOrderId(), + value.getInboundOrderNo(), value.getInboundCode(), value.getSkuCode(), value.getProductNameZh(), value.getProductNameEn(), + totalPackages, Math.max(0, totalPackages - holdPackages), holdPackages, allocatedPackages, + pickedPackages, inTransitPackages, + receivingPackages, procurementPackages, value.getUpdatedAt()); + } + + private InventoryPositionView positionView(InventoryPosition value, InventoryBalance balance) { + long available = number(value.getAvailablePackages()); + long hold = number(value.getHoldPackages()); + long allocated = number(value.getAllocatedPackages()); + long picked = number(value.getPickedPackages()); + long receiving = number(value.getReceivingPackages()); + long total = available + hold + allocated + picked; + return new InventoryPositionView(value.getId(), value.getBalanceId(), value.getCustomerId(), + value.getOverseasWarehouseId(), value.getDestinationWarehouseCode(), balance.getInboundOrderId(), + balance.getInboundOrderNo(), balance.getInboundCode(), + value.getPalletId(), value.getPalletNo(), value.getBatchNo(), value.getWarehouseLocationId(), + value.getWarehouseLocationCode(), total, Math.max(0, total - hold), hold, allocated, picked, + receiving, value.getStatus(), + value.getUpdatedAt()); + } + + private InventoryLedgerView ledgerView(InventoryLedger value) { + return new InventoryLedgerView(value.getId(), value.getCustomerId(), value.getOverseasWarehouseId(), + value.getDestinationWarehouseCode(), value.getInboundOrderId(), value.getInboundOrderNo(), value.getInboundCode(), + value.getPalletNo(), value.getBatchNo(), + value.getWarehouseLocationCode(), value.getSkuCode(), value.getBusinessType(), value.getBusinessOrderNo(), + value.getOutboundOrderNo(), value.getStatusFrom(), value.getStatusTo(), + number(value.getBeforePackages()), number(value.getDeltaPackages()), number(value.getAfterPackages()), + value.getOperatorId(), value.getRemark(), value.getOccurredAt()); + } + + private static void validateStatus(String status, Set accepted) { + if (status != null && !status.isBlank() && !accepted.contains(status)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported inventory status: " + status); + } + } + + private static void validateDateRange(InventoryQueryRequests.LedgerQuery query) { + if (query.occurredFrom() != null && query.occurredTo() != null + && query.occurredFrom().isAfter(query.occurredTo())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "occurredFrom must not be after occurredTo"); + } + } + + private static long offset(long page, long size) { + return Math.multiplyExact(page - 1, size); + } + + private static long number(Long value) { + return value == null ? 0 : value; + } + + private static PageResult page(List records, long page, long size, long total) { + long pages = total == 0 ? 0 : (total + size - 1) / size; + return new PageResult<>(records, page, size, total, pages); + } + + private static String csv(Object value) { + if (value == null) return ""; + String text = String.valueOf(value); + if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text; + return '"' + text.replace("\"", "\"\"") + '"'; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/LooseboardMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/LooseboardMapperConfiguration.java new file mode 100644 index 000000000..57bf6d4b3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/LooseboardMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.looseboard; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.looseboard.mapper") +public class LooseboardMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardController.java new file mode 100644 index 000000000..a201c41a0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardController.java @@ -0,0 +1,97 @@ +package com.baiyue.lastleg.looseboard.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses; +import com.baiyue.lastleg.looseboard.service.LocalLooseboardService; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.annotation.Resource; +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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/local-looseboard-orders") +public class PlatformLocalLooseboardController { + @Resource private LocalLooseboardService service; + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_LIST + "')") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, @Valid LocalLooseboardRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.page(query)); + } + + @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_CREATE + "')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @Valid @RequestBody LocalLooseboardRequests.Create request) { + requirePermission(user, OverseasPermissions.LOOSEBOARD_CREATE); + return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request)); + } + + @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_LIST + "')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo) { + requirePlatform(user); + return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo)); + } + + @PutMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_EDIT + "')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody LocalLooseboardRequests.Update request) { + requirePermission(user, OverseasPermissions.LOOSEBOARD_EDIT); + return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PatchMapping("/{orderNo}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LOOSEBOARD_STATUS + "')") + public ApiResponse changeStatus(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody LocalLooseboardRequests.ChangeStatus request) { + requirePermission(user, OverseasPermissions.LOOSEBOARD_STATUS); + return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + } + + private void requirePermission(AuthenticatedUser user, String permission) { + requirePlatform(user); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); + } + } + + private String requiredCustomer(String customerId) { + if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required"); + } + return customerId.trim(); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java new file mode 100644 index 000000000..0b94032df --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java @@ -0,0 +1,73 @@ +package com.baiyue.lastleg.looseboard.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public final class LocalLooseboardRequests { + private LocalLooseboardRequests() { } + + public record Create( + @NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode, + @NotBlank @Pattern(regexp = "YES|NO") String palletRequired, + @NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType, + @NotBlank @Size(max = 255) String origin, + @Size(max = 500) String originAddress, + @NotBlank @Size(max = 255) String destination, + @Size(max = 500) String destinationAddress, + @Size(max = 32) String containerType, + @NotNull @Min(0) Integer packageCount, + @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, + @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, + @DecimalMin("0.00") BigDecimal budgetAmount, + @Size(min = 3, max = 3) String currency, + @Size(max = 128) String contactName, + @Size(max = 128) String contactInfo, + LocalDate requestedDate, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Update( + @NotNull @Min(0) Long version, + @NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode, + @NotBlank @Pattern(regexp = "YES|NO") String palletRequired, + @NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType, + @NotBlank @Size(max = 255) String origin, + @Size(max = 500) String originAddress, + @NotBlank @Size(max = 255) String destination, + @Size(max = 500) String destinationAddress, + @Size(max = 32) String containerType, + @NotNull @Min(0) Integer packageCount, + @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, + @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, + @DecimalMin("0.00") BigDecimal budgetAmount, + @Size(min = 3, max = 3) String currency, + @Size(max = 128) String contactName, + @Size(max = 128) String contactInfo, + LocalDate requestedDate, + @Size(max = 1000) String remark) { } + + public record Query( + @Min(1) long page, + @Min(1) @Max(200) long size, + String customerId, + String orderNo, + String routeKeyword, + String status, + String transportMode, + LocalDate requestedFrom, + LocalDate requestedTo) { } + + public record ChangeStatus( + @NotNull @Min(0) Long version, + @NotBlank @Size(max = 32) String status, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java new file mode 100644 index 000000000..e8b930560 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.looseboard.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class LocalLooseboardResponses { + private LocalLooseboardResponses() { } + + public record Summary( + Long id, String customerId, String orderNo, String transportMode, String palletRequired, + String transportType, String origin, String destination, String containerType, int packageCount, + BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency, + String contactName, String contactInfo, LocalDate requestedDate, String status, long version, + LocalDateTime createdAt, LocalDateTime updatedAt) { } + + public record History( + String operationType, String fromStatus, String toStatus, String operatorId, + String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { } + + public record Detail( + Summary summary, String originAddress, String destinationAddress, String remark, + String createdBy, List histories) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java new file mode 100644 index 000000000..57dca5081 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.looseboard.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; + +@Data +@TableName("ll_local_looseboard_history") +public class LocalLooseboardHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String operationType; + private String fromStatus; + private String toStatus; + private String operatorId; + private String idempotencyKey; + private String changeDetail; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java new file mode 100644 index 000000000..f016e7517 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.looseboard.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; + +@Data +@TableName("ll_local_looseboard_operation") +public class LocalLooseboardOperation { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String operationType; + private String idempotencyKey; + private String requestHash; + private Long resultVersion; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java new file mode 100644 index 000000000..1017aaf3d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.looseboard.entity; + +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.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_local_looseboard_order") +public class LocalLooseboardOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long lastlegOrderId; + private String orderNo; + private String transportMode; + private String palletRequired; + private String transportType; + private String origin; + private String originAddress; + private String destination; + private String destinationAddress; + private String containerType; + private Integer packageCount; + private BigDecimal totalWeightKg; + private BigDecimal totalVolumeCbm; + private BigDecimal budgetAmount; + private String currency; + private String contactName; + private String contactInfo; + private LocalDate requestedDate; + private String status; + private String remark; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardHistoryMapper.java new file mode 100644 index 000000000..99998991c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardHistoryMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.looseboard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; +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 LocalLooseboardHistoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_local_looseboard_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java new file mode 100644 index 000000000..061fbbd57 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.looseboard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface LocalLooseboardOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_local_looseboard_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1") + LocalLooseboardOperation findKey(@Param("customerId") String customerId, + @Param("orderId") Long orderId, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java new file mode 100644 index 000000000..dee9ef276 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.looseboard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface LocalLooseboardOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1") + LocalLooseboardOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); + + @Select("SELECT * FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + LocalLooseboardOrder findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE") + LocalLooseboardOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java new file mode 100644 index 000000000..b50c883ba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -0,0 +1,388 @@ +package com.baiyue.lastleg.looseboard.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@Service +public class LocalLooseboardService { + public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE"; + public static final String DISPATCHED = "DISPATCHED"; + public static final String IN_TRANSIT = "IN_TRANSIT"; + public static final String COMPLETED = "COMPLETED"; + public static final String CANCELLED = "CANCELLED"; + private static final Set STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED); + private static final Set MODES = Set.of("SHARED_TRUCK", "PRIORITY_CONSOLIDATION"); + private static final Map> TRANSITIONS = Map.of( + PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED), + DISPATCHED, Set.of(IN_TRANSIT, CANCELLED), + IN_TRANSIT, Set.of(COMPLETED)); + + @Resource private LocalLooseboardOrderMapper orders; + @Resource private LocalLooseboardHistoryMapper histories; + @Resource private LocalLooseboardOperationMapper operations; + @Resource private LastlegOrderMapper lastlegOrders; + @Resource private LastlegOrderStatusHistoryMapper lastlegHistories; + @Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator; + @Resource private ObjectMapper objectMapper; + + @Transactional + public LocalLooseboardResponses.Detail create(String customerId, String operatorId, + LocalLooseboardRequests.Create request) { + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + LocalLooseboardOrder replay = orders.findKey(customerId, key); + if (replay != null) return replay(customerId, replay, requestHash); + LocalDateTime now = LocalDateTime.now(); + LocalLooseboardOrder order = new LocalLooseboardOrder(); + order.setId(IdWorker.getId()); + order.setCustomerId(customerId); + order.setOrderNo("LB" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId()); + apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(), + request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(), + request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), + request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark()); + order.setStatus(PENDING_ACCEPTANCE); + order.setIdempotencyKey(key); + order.setRequestHash(requestHash); + order.setCreatedBy(operatorId); + order.setVersion(0L); + order.setCreatedAt(now); + order.setUpdatedAt(now); + LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now); + order.setLastlegOrderId(lastlegOrder.getId()); + try { + orders.insert(order); + } catch (DuplicateKeyException exception) { + LocalLooseboardOrder concurrent = orders.findKey(customerId, key); + if (concurrent != null) return replay(customerId, concurrent, requestHash); + throw exception; + } + history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, + key, null, request.remark(), now); + lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(), + "Local looseboard order submitted", now); + return detail(customerId, order.getOrderNo()); + } + + public PageResult page(LocalLooseboardRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId())) + .like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo())) + .and(StringUtils.hasText(query.routeKeyword()), value -> value + .like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword())) + .or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword()))) + .eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus, + StringUtils.hasText(query.status()) ? norm(query.status()) : null) + .eq(StringUtils.hasText(query.transportMode()), LocalLooseboardOrder::getTransportMode, + StringUtils.hasText(query.transportMode()) ? norm(query.transportMode()) : null) + .ge(query.requestedFrom() != null, LocalLooseboardOrder::getRequestedDate, query.requestedFrom()) + .le(query.requestedTo() != null, LocalLooseboardOrder::getRequestedDate, query.requestedTo()) + .orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId); + Page result = orders.selectPage(Page.of(query.page(), query.size()), wrapper); + return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(), + result.getSize(), result.getTotal(), result.getPages()); + } + + public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) { + LocalLooseboardOrder order = orders.findNo(customerId, orderNo); + if (order == null) throw notFound(); + return response(order); + } + + @Transactional + public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId, + LocalLooseboardRequests.Update request) { + LocalLooseboardOrder order = lock(customerId, orderNo); + if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); + if (!PENDING_ACCEPTANCE.equals(order.getStatus())) { + throw conflict("Only pending-acceptance looseboard orders can be edited"); + } + Map before = snapshot(order); + apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(), + request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(), + request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), + request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark()); + Map after = snapshot(order); + if (before.equals(after)) return response(order); + LocalDateTime now = LocalDateTime.now(); + touch(order, now); + orders.updateById(order); + history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, + null, changeDetail(before, after), null, now); + return response(order); + } + + @Transactional + public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, + LocalLooseboardRequests.ChangeStatus request) { + LocalLooseboardOrder order = lock(customerId, orderNo); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key); + if (existing != null) { + if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) { + throw conflict("Idempotency key was reused with a different looseboard status payload"); + } + return response(order); + } + if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); + String next = norm(request.status()); + if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status"); + String from = order.getStatus(); + if (next.equals(from)) return response(order); + if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) { + throw conflict("Unsupported looseboard order transition: " + from + " -> " + next); + } + LocalDateTime now = LocalDateTime.now(); + order.setStatus(next); + touch(order, now); + orders.updateById(order); + syncLastlegOrder(order, next, now); + LocalLooseboardOperation operation = new LocalLooseboardOperation(); + operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); + operation.setCustomerId(customerId); operation.setOperationType("STATUS"); + operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); + operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); + operation.setCreatedAt(now); operations.insert(operation); + history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now); + return response(order); + } + + private void apply(LocalLooseboardOrder order, String mode, String palletRequired, String transportType, + String origin, String originAddress, String destination, String destinationAddress, + String containerType, Integer packageCount, java.math.BigDecimal totalWeightKg, + java.math.BigDecimal totalVolumeCbm, java.math.BigDecimal budgetAmount, String currency, + String contactName, String contactInfo, java.time.LocalDate requestedDate, String remark) { + order.setTransportMode(norm(mode)); + order.setPalletRequired(norm(palletRequired)); + order.setTransportType(norm(transportType)); + order.setOrigin(origin.trim()); + order.setOriginAddress(trim(originAddress)); + order.setDestination(destination.trim()); + order.setDestinationAddress(trim(destinationAddress)); + order.setContainerType(trim(containerType)); + order.setPackageCount(packageCount); + order.setTotalWeightKg(totalWeightKg); + order.setTotalVolumeCbm(totalVolumeCbm); + order.setBudgetAmount(budgetAmount); + order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD"); + order.setContactName(trim(contactName)); + order.setContactInfo(trim(contactInfo)); + order.setRequestedDate(requestedDate); + order.setRemark(trim(remark)); + } + + private LocalLooseboardResponses.Detail replay(String customerId, LocalLooseboardOrder order, String hash) { + if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different looseboard data"); + return detail(customerId, order.getOrderNo()); + } + + private LocalLooseboardOrder lock(String customerId, String orderNo) { + LocalLooseboardOrder order = orders.lockNo(customerId, orderNo); + if (order == null) throw notFound(); + return order; + } + + private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) { + return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(), + order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(), + histories.listOrder(order.getCustomerId(), order.getId()).stream() + .map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(), + value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), + value.getChangeDetail(), value.getRemark(), + value.getCreatedAt())).toList()); + } + + private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) { + return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(), + order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(), + order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0), + order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(), + order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(), + value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); + } + + private void history(LocalLooseboardOrder order, String type, String from, String to, String operator, + String idempotencyKey, String changeDetail, String remark, LocalDateTime now) { + LocalLooseboardHistory history = new LocalLooseboardHistory(); + history.setId(IdWorker.getId()); + history.setOrderId(order.getId()); + history.setCustomerId(order.getCustomerId()); + history.setOperationType(type); + history.setFromStatus(from); + history.setToStatus(to); + history.setOperatorId(operator); + history.setIdempotencyKey(trim(idempotencyKey)); + history.setChangeDetail(changeDetail); + history.setRemark(trim(remark)); + history.setCreatedAt(now); + histories.insert(history); + } + + private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) { + LastlegOrder order = new LastlegOrder(); + order.setId(IdWorker.getId()); + order.setOrderNo(lastlegOrderNoGenerator.next()); + order.setCustomerId(source.getCustomerId()); + order.setOrderType("LOCAL_LOOSEBOARD"); + order.setInboundCode(source.getOrderNo()); + order.setSourceRegion("OVERSEAS_LOCAL"); + order.setSourceInboundId("LOCAL_LOOSEBOARD:" + source.getId()); + order.setSourceOrderId(String.valueOf(source.getId())); + order.setSourceOrderNo(source.getOrderNo()); + order.setPalletCount(0); + order.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); + order.setPickedPackageCount(0); + order.setShippedPackageCount(0); + order.setVolumeCbm(source.getTotalVolumeCbm()); + order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setSubOrderStatus("LOCAL_LOOSEBOARD:" + PENDING_ACCEPTANCE); + order.setCustomerRemark(source.getRemark()); + order.setSourceEventId("LOCAL_LOOSEBOARD:" + source.getId()); + order.setSourceLoadingPlanId("LOCAL_LOOSEBOARD"); + order.setSourcePayloadHash("sha256:" + requestHash); + order.setCreatedAt(now); + order.setUpdatedAt(now); + lastlegOrders.insert(order); + return order; + } + + private void syncLastlegOrder(LocalLooseboardOrder source, String status, LocalDateTime now) { + if (source.getLastlegOrderId() == null) return; + LastlegOrder order = lastlegOrders.selectById(source.getLastlegOrderId()); + if (order == null || !source.getCustomerId().equals(order.getCustomerId())) { + throw conflict("Linked tail-leg order is missing"); + } + order.setSubOrderStatus("LOCAL_LOOSEBOARD:" + status); + order.setUpdatedAt(now); + lastlegOrders.updateById(order); + } + + private void lastlegHistory(String customerId, Long orderId, String from, String to, String remark, + LocalDateTime now) { + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setCustomerId(customerId); + history.setOrderId(orderId); + history.setFromStatus(from); + history.setToStatus(to); + history.setEventId("LOCAL_LOOSEBOARD"); + history.setRemark(remark); + history.setCreatedAt(now); + lastlegHistories.insert(history); + } + + private void validateQuery(LocalLooseboardRequests.Query query) { + if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) { + throw badRequest("Unsupported looseboard order status"); + } + if (StringUtils.hasText(query.transportMode()) && !MODES.contains(norm(query.transportMode()))) { + throw badRequest("Unsupported looseboard transport mode"); + } + if (query.requestedFrom() != null && query.requestedTo() != null + && query.requestedFrom().isAfter(query.requestedTo())) { + throw badRequest("requestedFrom must not be after requestedTo"); + } + } + + private Map snapshot(LocalLooseboardOrder order) { + Map value = new LinkedHashMap<>(); + value.put("transportMode", order.getTransportMode()); + value.put("palletRequired", order.getPalletRequired()); + value.put("transportType", order.getTransportType()); + value.put("origin", order.getOrigin()); + value.put("originAddress", order.getOriginAddress()); + value.put("destination", order.getDestination()); + value.put("destinationAddress", order.getDestinationAddress()); + value.put("containerType", order.getContainerType()); + value.put("packageCount", order.getPackageCount()); + value.put("totalWeightKg", order.getTotalWeightKg()); + value.put("totalVolumeCbm", order.getTotalVolumeCbm()); + value.put("budgetAmount", order.getBudgetAmount()); + value.put("currency", order.getCurrency()); + value.put("contactName", order.getContactName()); + value.put("contactInfo", order.getContactInfo()); + value.put("requestedDate", order.getRequestedDate()); + value.put("remark", order.getRemark()); + return value; + } + + private String changeDetail(Map before, Map after) { + Map changes = new LinkedHashMap<>(); + before.forEach((field, oldValue) -> { + Object newValue = after.get(field); + if (!Objects.equals(oldValue, newValue)) { + Map pair = new LinkedHashMap<>(); + pair.put("before", oldValue); + pair.put("after", newValue); + changes.put(field, pair); + } + }); + try { + return objectMapper.writeValueAsString(changes); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize looseboard change detail", exception); + } + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsBytes(value))); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("Cannot hash looseboard request", exception); + } + } + + private static void touch(LocalLooseboardOrder order, LocalDateTime now) { + order.setVersion(value(order.getVersion()) + 1); + order.setUpdatedAt(now); + } + + private static long value(Long value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String norm(String value) { return value.trim().toUpperCase(); } + private static ResponseStatusException badRequest(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + private static ResponseStatusException notFound() { + return new ResponseStatusException(HttpStatus.NOT_FOUND, "Local looseboard order not found"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java new file mode 100644 index 000000000..ec4dc3988 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegEventInbox; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegEventInboxMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java new file mode 100644 index 000000000..767551233 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface LastlegEventOutboxMapper extends BaseMapper { + @Select("SELECT * FROM ll_event_outbox WHERE event_key=#{eventKey} LIMIT 1") + LastlegEventOutbox findByEventKey(@Param("eventKey") String eventKey); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegInboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegInboundOrderMapper.java new file mode 100644 index 000000000..40b003ea3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegInboundOrderMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegInboundOrder; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegInboundOrderMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java new file mode 100644 index 000000000..1b5a957a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrderDetail; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegOrderDetailMapper extends BaseMapper {} 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 new file mode 100644 index 000000000..9973bdca2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface LastlegOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " + + "AND source_inbound_id=#{sourceInboundId} LIMIT 1") + LastlegOrder findSource(@Param("customerId") String customerId, + @Param("sourceRegion") String sourceRegion, + @Param("sourceInboundId") String sourceInboundId); + + @Select("SELECT COUNT(*) FROM ll_order WHERE customer_id=#{customerId} AND container_no=#{containerNo}") + long countContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo); + + @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") + LastlegOrder lockById(@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 package_count-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 " + + "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 picked_package_count >= #{packages} " + + "AND shipped_package_count+#{packages} <= package_count") + int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id, + @Param("packages") long packages, @Param("outboundNo") String outboundNo); + + @Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_name=#{warehouseName}," + + "updated_at=NOW(3) WHERE id=#{orderId} AND customer_id=#{customerId} " + + "AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})") + int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("orderId") long orderId, + @Param("warehouseId") String warehouseId, + @Param("warehouseName") String warehouseName); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java new file mode 100644 index 000000000..4a52568cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegOrderStatusHistoryMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java new file mode 100644 index 000000000..5a24a2e5e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java @@ -0,0 +1,5 @@ +/** + * MyBatis-Plus single-table data access for the last-leg database. + * / 尾程数据库的 MyBatis-Plus 单表访问层。 + */ +package com.baiyue.lastleg.mapper; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/MasterDataMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/MasterDataMapperConfiguration.java new file mode 100644 index 000000000..966a58806 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/MasterDataMapperConfiguration.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.masterdata; + +import org.apache.ibatis.annotations.Mapper; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan(basePackages = "com.baiyue.lastleg.masterdata", annotationClass = Mapper.class) +public class MasterDataMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java new file mode 100644 index 000000000..25dbab049 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.masterdata.exchange.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos; +import com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +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 java.nio.charset.StandardCharsets; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/master-data/exchange-rates") +@Tag(name = "Monthly exchange rates / 月度汇率") +public class PlatformMonthlyExchangeRateController { + @Resource private MonthlyExchangeRateService service; + + @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')") + @Operation(summary = "Page monthly exchange rates", description = "Required authority: platform:overseas-master-data:exchange-rate:list") + public ApiResponse> page(@Valid MonthlyExchangeRateDtos.Query query) { + return ApiResponse.success(service.page(query)); + } + @GetMapping("/summary") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')") + public ApiResponse summary() { return ApiResponse.success(service.summary()); } + @GetMapping("/{month}") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_LIST + "')") + public ApiResponse detail(@PathVariable String month) { return ApiResponse.success(service.detail(month)); } + @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_CREATE + "')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody MonthlyExchangeRateDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); } + @PutMapping("/{month}") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EDIT + "')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.Update request) { + return ApiResponse.success(service.update(month, operator(user), request)); + } + @PatchMapping("/{month}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_STATUS + "')") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String month, @Valid @RequestBody MonthlyExchangeRateDtos.ChangeStatus request) { + return ApiResponse.success(service.changeStatus(month, operator(user), request)); + } + @GetMapping(value = "/export", produces = "text/csv") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCHANGE_RATE_EXPORT + "')") + public ResponseEntity export(@Valid MonthlyExchangeRateDtos.Query query) { + return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=monthly-exchange-rates.csv") + .body(service.exportCsv(query)); + } + private String operator(AuthenticatedUser user) { return user == null ? "UNKNOWN" : user.userId() == null ? user.username() : String.valueOf(user.userId()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/dto/MonthlyExchangeRateDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/dto/MonthlyExchangeRateDtos.java new file mode 100644 index 000000000..7259e7f0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/dto/MonthlyExchangeRateDtos.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.masterdata.exchange.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public final class MonthlyExchangeRateDtos { + private MonthlyExchangeRateDtos() { } + + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, + @Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String monthFrom, + @Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String monthTo, + String status) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + } + public record Create(@NotBlank @Pattern(regexp = "\\d{4}-(0[1-9]|1[0-2])") String month, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal usdToCnyAverage, + @Size(max = 1000) String remark) { } + public record Update(@NotNull @Min(0) Long recordVersion, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal usdToCnyAverage, + @Size(max = 1000) String remark) { } + public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion, + @Size(max = 500) String remark) { } + public record Response(String month, String baseCurrency, String quoteCurrency, + BigDecimal usdToCnyAverage, String status, long recordVersion, + String updatedBy, LocalDateTime updatedAt, String remark) { } + public record Summary(String latestMonth, BigDecimal latestUsdToCnyAverage, + String updatedBy, LocalDateTime updatedAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/entity/MonthlyExchangeRate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/entity/MonthlyExchangeRate.java new file mode 100644 index 000000000..b47761761 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/entity/MonthlyExchangeRate.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.masterdata.exchange.entity; + +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_monthly_exchange_rate") +public class MonthlyExchangeRate { + @TableId(type = IdType.INPUT) private Long id; + private String rateMonth; + private String baseCurrency; + private String quoteCurrency; + private BigDecimal usdToCnyAverage; + private String status; + private String remark; + 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/masterdata/exchange/entity/MonthlyExchangeRateHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/entity/MonthlyExchangeRateHistory.java new file mode 100644 index 000000000..9845ad1f4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/entity/MonthlyExchangeRateHistory.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.masterdata.exchange.entity; + +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_monthly_exchange_rate_history") +public class MonthlyExchangeRateHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long exchangeRateId; + private BigDecimal beforeRate; + private BigDecimal afterRate; + private String beforeStatus; + private String afterStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateHistoryMapper.java new file mode 100644 index 000000000..5180fd17e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateHistoryMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.masterdata.exchange.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRateHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface MonthlyExchangeRateHistoryMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateMapper.java new file mode 100644 index 000000000..4fa553126 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.masterdata.exchange.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface MonthlyExchangeRateMapper extends BaseMapper { + @Select("SELECT * FROM ll_monthly_exchange_rate WHERE rate_month=#{month} LIMIT 1") + MonthlyExchangeRate findMonth(@Param("month") String month); + + @Select("SELECT * FROM ll_monthly_exchange_rate WHERE rate_month=#{month} LIMIT 1 FOR UPDATE") + MonthlyExchangeRate lockMonth(@Param("month") String month); + + @Select("SELECT * FROM ll_monthly_exchange_rate WHERE status='ACTIVE' ORDER BY rate_month DESC LIMIT 1") + MonthlyExchangeRate latestActive(); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateService.java new file mode 100644 index 000000000..6817cce0a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateService.java @@ -0,0 +1,156 @@ +package com.baiyue.lastleg.masterdata.exchange.service; + +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.exchange.dto.MonthlyExchangeRateDtos; +import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate; +import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRateHistory; +import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateHistoryMapper; +import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Locale; + +@Service +public class MonthlyExchangeRateService { + private static final String ACTIVE = "ACTIVE"; + private static final String INACTIVE = "INACTIVE"; + @Resource private MonthlyExchangeRateMapper rates; + @Resource private MonthlyExchangeRateHistoryMapper histories; + + public PageResult page(MonthlyExchangeRateDtos.Query query) { + validateRange(query.monthFrom(), query.monthTo()); + String status = optionalStatus(query.status()); + Page page = rates.selectPage(Page.of(query.page(), query.size()), + new LambdaQueryWrapper() + .ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom()) + .le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo()) + .eq(status != null, MonthlyExchangeRate::getStatus, status) + .orderByDesc(MonthlyExchangeRate::getRateMonth)); + return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), + page.getSize(), page.getTotal(), page.getPages()); + } + + public MonthlyExchangeRateDtos.Response detail(String month) { return response(required(month)); } + + public MonthlyExchangeRateDtos.Summary summary() { + MonthlyExchangeRate value = rates.latestActive(); + return value == null ? new MonthlyExchangeRateDtos.Summary(null, null, null, null) + : new MonthlyExchangeRateDtos.Summary(value.getRateMonth(), value.getUsdToCnyAverage(), + value.getUpdatedBy(), value.getUpdatedAt()); + } + + @Transactional + public MonthlyExchangeRateDtos.Response create(String operator, MonthlyExchangeRateDtos.Create request) { + String month = month(request.month()); LocalDateTime now = LocalDateTime.now(); + MonthlyExchangeRate value = new MonthlyExchangeRate(); value.setId(IdWorker.getId()); + value.setRateMonth(month); value.setBaseCurrency("USD"); value.setQuoteCurrency("CNY"); + value.setUsdToCnyAverage(request.usdToCnyAverage()); value.setStatus(ACTIVE); + value.setRemark(trim(request.remark())); value.setRecordVersion(0L); + value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now); + try { rates.insert(value); } + catch (DuplicateKeyException exception) { throw conflict("该月份汇率已存在"); } + history(value.getId(), null, value.getUsdToCnyAverage(), null, ACTIVE, operator, request.remark()); + return detail(month); + } + + @Transactional + public MonthlyExchangeRateDtos.Response update(String month, String operator, + MonthlyExchangeRateDtos.Update request) { + MonthlyExchangeRate current = locked(month); requireVersion(current, request.recordVersion()); + int updated = rates.update(null, new LambdaUpdateWrapper() + .eq(MonthlyExchangeRate::getId, current.getId()) + .eq(MonthlyExchangeRate::getRecordVersion, request.recordVersion()) + .set(MonthlyExchangeRate::getUsdToCnyAverage, request.usdToCnyAverage()) + .set(MonthlyExchangeRate::getRemark, trim(request.remark())) + .set(MonthlyExchangeRate::getUpdatedBy, operator).set(MonthlyExchangeRate::getUpdatedAt, LocalDateTime.now()) + .setSql("record_version = record_version + 1")); + if (updated != 1) throw conflict("汇率已被其他操作修改,请刷新后重试"); + history(current.getId(), current.getUsdToCnyAverage(), request.usdToCnyAverage(), + current.getStatus(), current.getStatus(), operator, request.remark()); + return detail(current.getRateMonth()); + } + + @Transactional + public MonthlyExchangeRateDtos.Response changeStatus(String month, String operator, + MonthlyExchangeRateDtos.ChangeStatus request) { + MonthlyExchangeRate current = locked(month); requireVersion(current, request.recordVersion()); + String status = status(request.status()); + if (status.equals(current.getStatus())) return response(current); + int updated = rates.update(null, new LambdaUpdateWrapper() + .eq(MonthlyExchangeRate::getId, current.getId()) + .eq(MonthlyExchangeRate::getRecordVersion, request.recordVersion()) + .set(MonthlyExchangeRate::getStatus, status).set(MonthlyExchangeRate::getUpdatedBy, operator) + .set(MonthlyExchangeRate::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1")); + if (updated != 1) throw conflict("汇率已被其他操作修改,请刷新后重试"); + history(current.getId(), current.getUsdToCnyAverage(), current.getUsdToCnyAverage(), + current.getStatus(), status, operator, request.remark()); + return detail(current.getRateMonth()); + } + + public byte[] exportCsv(MonthlyExchangeRateDtos.Query query) { + validateRange(query.monthFrom(), query.monthTo()); + String status = optionalStatus(query.status()); + var rows = rates.selectList(new LambdaQueryWrapper() + .ge(StringUtils.hasText(query.monthFrom()), MonthlyExchangeRate::getRateMonth, query.monthFrom()) + .le(StringUtils.hasText(query.monthTo()), MonthlyExchangeRate::getRateMonth, query.monthTo()) + .eq(status != null, MonthlyExchangeRate::getStatus, status) + .orderByDesc(MonthlyExchangeRate::getRateMonth)).stream().map(this::response).toList(); + StringBuilder csv = new StringBuilder("\uFEFF月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n"); + for (var row : rows) csv.append(row.month()).append(',').append(row.baseCurrency()).append(',') + .append(row.quoteCurrency()).append(',').append(row.usdToCnyAverage()).append(',') + .append(row.status()).append(',').append(cell(row.updatedBy())).append(',').append(row.updatedAt()).append("\r\n"); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + private void history(long id, java.math.BigDecimal beforeRate, java.math.BigDecimal afterRate, + String beforeStatus, String afterStatus, String operator, String remark) { + MonthlyExchangeRateHistory value = new MonthlyExchangeRateHistory(); value.setId(IdWorker.getId()); + value.setExchangeRateId(id); value.setBeforeRate(beforeRate); value.setAfterRate(afterRate); + value.setBeforeStatus(beforeStatus); value.setAfterStatus(afterStatus); value.setOperatorId(operator); + value.setRemark(trim(remark)); value.setCreatedAt(LocalDateTime.now()); histories.insert(value); + } + private MonthlyExchangeRate required(String month) { + MonthlyExchangeRate value = rates.findMonth(month(month)); + if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "月度汇率不存在"); return value; + } + private MonthlyExchangeRate locked(String month) { + MonthlyExchangeRate value = rates.lockMonth(month(month)); + if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "月度汇率不存在"); return value; + } + private MonthlyExchangeRateDtos.Response response(MonthlyExchangeRate value) { + return new MonthlyExchangeRateDtos.Response(value.getRateMonth(), value.getBaseCurrency(), + value.getQuoteCurrency(), value.getUsdToCnyAverage(), value.getStatus(), version(value.getRecordVersion()), + value.getUpdatedBy(), value.getUpdatedAt(), value.getRemark()); + } + private void requireVersion(MonthlyExchangeRate value, long expected) { + if (version(value.getRecordVersion()) != expected) throw conflict("汇率已被其他操作修改,请刷新后重试"); + } + private void validateRange(String from, String to) { + if (StringUtils.hasText(from) && StringUtils.hasText(to) && from.compareTo(to) > 0) throw badRequest("monthFrom不能晚于monthTo"); + } + private String month(String value) { + if (value == null || !value.matches("\\d{4}-(0[1-9]|1[0-2])")) throw badRequest("月份格式必须为yyyy-MM"); return value; + } + private String optionalStatus(String value) { return StringUtils.hasText(value) ? status(value) : null; } + private String status(String value) { + String result = value.trim().toUpperCase(Locale.ROOT); + if (!ACTIVE.equals(result) && !INACTIVE.equals(result)) throw badRequest("汇率状态只支持ACTIVE/INACTIVE"); return result; + } + private static long version(Long value) { return value == null ? 0L : value; } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; } + private static ResponseStatusException badRequest(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/masterdata/quote/client/DomesticPartnerWarehouseClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java new file mode 100644 index 000000000..2134b4338 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.masterdata.quote.client; + +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 java.util.List; + +@FeignClient(name = "warehouse-service", contextId = "domesticPartnerWarehouseClient", + url = "${baiyue.clients.domestic-warehouse.url:}") +public interface DomesticPartnerWarehouseClient { + @GetMapping("/internal/warehouses/overseas-partners") + ApiResponse> activePartners( + @RequestHeader("X-Internal-Service-Token") String internalToken); + + record Option(Long id, String warehouseCode, String warehouseName, String countryCode) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java new file mode 100644 index 000000000..70836ade5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java @@ -0,0 +1,110 @@ +package com.baiyue.lastleg.masterdata.quote.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests; +import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses; +import com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +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.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/master-data/overseas-quotes") +@Tag(name = "Overseas warehouse quotes / 海外仓报价") +public class PlatformOverseasQuoteController { + @Resource private OverseasQuoteService service; + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')") + @Operation(summary = "Page overseas warehouse quotes", + description = "Required authority: platform:overseas-master-data:quote:list") + public ApiResponse> page( + @Valid OverseasQuoteRequests.Query query) { + return ApiResponse.success(service.page(query)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')") + @Operation(summary = "Get overseas warehouse quote detail", + description = "Required authority: platform:overseas-master-data:quote:list") + public ApiResponse detail(@PathVariable long id) { + return ApiResponse.success(service.detail(id)); + } + + @GetMapping("/warehouse-options") + @PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')") + @Operation(summary = "List cached active overseas partner warehouse options", + description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs; " + + "refresh=true bypasses the positive cache after CN partner master data changes") + public ApiResponse> warehouseOptions( + @RequestParam(defaultValue = "false") boolean refresh) { + return ApiResponse.success(service.warehouseOptions(refresh)); + } + + @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_CREATE + "')") + @Operation(summary = "Create an inactive overseas warehouse quote", + description = "Required authority: platform:overseas-master-data:quote:create") + public ApiResponse create( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody OverseasQuoteRequests.Create request) { + return ApiResponse.success(service.create(operator(user), request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_EDIT + "')") + @Operation(summary = "Update an inactive overseas warehouse quote", + description = "Required authority: platform:overseas-master-data:quote:edit") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, + @Valid @RequestBody OverseasQuoteRequests.Update request) { + return ApiResponse.success(service.update(id, operator(user), request)); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_STATUS + "')") + @Operation(summary = "Enable or disable an overseas warehouse quote", + description = "Required authority: platform:overseas-master-data:quote:status") + public ApiResponse status( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, + @Valid @RequestBody OverseasQuoteRequests.ChangeStatus request) { + return ApiResponse.success(service.changeStatus(id, operator(user), request)); + } + + @GetMapping(value = "/export", produces = "text/csv") + @PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_EXPORT + "')") + @Operation(summary = "Export filtered overseas warehouse quotes", + description = "Required authority: platform:overseas-master-data:quote:export") + public ResponseEntity export(@Valid OverseasQuoteRequests.Query query) { + return ResponseEntity.ok() + .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=overseas-quotes.csv") + .body(service.exportCsv(query)); + } + + private String operator(AuthenticatedUser user) { + if (user == null) return "UNKNOWN"; + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteRequests.java new file mode 100644 index 000000000..43ff101d9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteRequests.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.masterdata.quote.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public final class OverseasQuoteRequests { + private OverseasQuoteRequests() { } + + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, + String keyword, String warehouseId, String providerName, + String currency, String status) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + } + + public record Fee(@NotBlank @Size(max = 128) String item, + @NotBlank @Size(max = 64) String billingUnit, + @NotNull @DecimalMin("0") BigDecimal unitPrice, + @DecimalMin("0") BigDecimal minimumCharge, + @Size(max = 500) String remark) { } + + public record TransitPrice(@NotBlank @Size(max = 64) String fbaWarehouseCode, + @NotBlank @Size(max = 64) String deliveryMethod, + @DecimalMin("0") BigDecimal estimatedMiles, + @NotNull @DecimalMin("0") BigDecimal price, + @DecimalMin("0") BigDecimal minimumCharge, + @Size(max = 500) String remark) { } + + public record Create(@NotBlank @Size(max = 128) String name, + @NotBlank String warehouseId, + @NotBlank @Size(max = 128) String providerName, + @NotBlank @Size(max = 3) String currency, + @NotNull LocalDate effectiveStart, + @NotNull LocalDate effectiveEnd, + @NotBlank @Size(max = 64) String versionCode, + @NotEmpty List<@Valid Fee> warehouseFees, + List<@Valid TransitPrice> transitPrices, + @Size(max = 1000) String remark) { } + + public record Update(@NotNull @Min(0) Long recordVersion, + @NotBlank @Size(max = 128) String name, + @NotBlank @Size(max = 128) String providerName, + @NotBlank @Size(max = 3) String currency, + @NotNull LocalDate effectiveStart, + @NotNull LocalDate effectiveEnd, + @NotEmpty List<@Valid Fee> warehouseFees, + List<@Valid TransitPrice> transitPrices, + @Size(max = 1000) String remark) { } + + public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteResponses.java new file mode 100644 index 000000000..e74ca80db --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteResponses.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.masterdata.quote.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class OverseasQuoteResponses { + private OverseasQuoteResponses() { } + + public record PartnerWarehouseOption(String warehouseId, String warehouseCode, + String warehouseName, String countryCode) { } + + public record Summary(String id, String name, String warehouseId, String warehouseCode, + String warehouseName, String countryCode, String providerName, + String currency, LocalDate effectiveStart, LocalDate effectiveEnd, + String versionCode, String status, long recordVersion, + String updatedBy, LocalDateTime updatedAt, String remark) { } + + public record Fee(String item, String billingUnit, BigDecimal unitPrice, + BigDecimal minimumCharge, String remark) { } + + public record TransitPrice(String fbaWarehouseCode, String deliveryMethod, + BigDecimal estimatedMiles, BigDecimal price, + BigDecimal minimumCharge, String remark) { } + + public record Detail(Summary summary, List warehouseFees, + List transitPrices, + String createdBy, LocalDateTime createdAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuote.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuote.java new file mode 100644 index 000000000..f40c0886d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuote.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.masterdata.quote.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.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_overseas_quote") +public class OverseasQuote { + @TableId(type = IdType.INPUT) private Long id; + private String name; + private Long warehouseId; + private String warehouseCode; + private String warehouseName; + private String countryCode; + private String providerName; + private String currency; + private LocalDate effectiveStart; + private LocalDate effectiveEnd; + private String versionCode; + private String status; + private String remark; + 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/masterdata/quote/entity/OverseasQuoteFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuoteFee.java new file mode 100644 index 000000000..0e820867a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuoteFee.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.masterdata.quote.entity; + +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; + +@Data +@TableName("ll_overseas_quote_fee") +public class OverseasQuoteFee { + @TableId(type = IdType.INPUT) private Long id; + private Long quoteId; + private Integer sequenceNo; + private String item; + private String billingUnit; + private BigDecimal unitPrice; + private BigDecimal minimumCharge; + private String remark; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuoteTransitPrice.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuoteTransitPrice.java new file mode 100644 index 000000000..20ca48403 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuoteTransitPrice.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.masterdata.quote.entity; + +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; + +@Data +@TableName("ll_overseas_quote_transit_price") +public class OverseasQuoteTransitPrice { + @TableId(type = IdType.INPUT) private Long id; + private Long quoteId; + private Integer sequenceNo; + private String fbaWarehouseCode; + private String deliveryMethod; + private BigDecimal estimatedMiles; + private BigDecimal price; + private BigDecimal minimumCharge; + private String remark; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteFeeMapper.java new file mode 100644 index 000000000..1c4d17173 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteFeeMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.masterdata.quote.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteFee; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OverseasQuoteFeeMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteMapper.java new file mode 100644 index 000000000..32b3dcc35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteMapper.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.masterdata.quote.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuote; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDate; + +@Mapper +public interface OverseasQuoteMapper extends BaseMapper { + @Select("SELECT * FROM ll_overseas_quote WHERE id=#{id} LIMIT 1 FOR UPDATE") + OverseasQuote lockById(@Param("id") long id); + + @Select(""" + SELECT COUNT(*) FROM ll_overseas_quote + WHERE warehouse_id=#{warehouseId} AND provider_name=#{providerName} AND currency=#{currency} + AND status='ACTIVE' AND id<>#{excludedId} + AND effective_start<=#{effectiveEnd} AND effective_end>=#{effectiveStart} + """) + int countActiveOverlap(@Param("warehouseId") long warehouseId, + @Param("providerName") String providerName, + @Param("currency") String currency, + @Param("effectiveStart") LocalDate effectiveStart, + @Param("effectiveEnd") LocalDate effectiveEnd, + @Param("excludedId") long excludedId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteTransitPriceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteTransitPriceMapper.java new file mode 100644 index 000000000..da064de14 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteTransitPriceMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.masterdata.quote.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteTransitPrice; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OverseasQuoteTransitPriceMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java new file mode 100644 index 000000000..766b5163f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java @@ -0,0 +1,227 @@ +package com.baiyue.lastleg.masterdata.quote.service; + +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.quote.dto.OverseasQuoteRequests; +import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses; +import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuote; +import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteFee; +import com.baiyue.lastleg.masterdata.quote.entity.OverseasQuoteTransitPrice; +import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteFeeMapper; +import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteMapper; +import com.baiyue.lastleg.masterdata.quote.mapper.OverseasQuoteTransitPriceMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; + +@Service +public class OverseasQuoteService { + public static final String ACTIVE = "ACTIVE"; + public static final String INACTIVE = "INACTIVE"; + @Resource private OverseasQuoteMapper quotes; + @Resource private OverseasQuoteFeeMapper fees; + @Resource private OverseasQuoteTransitPriceMapper transitPrices; + @Resource private PartnerWarehouseDirectory warehouses; + + public PageResult page(OverseasQuoteRequests.Query query) { + Long warehouseId = parseOptionalId(query.warehouseId()); + String status = normalizeOptionalStatus(query.status()); + var wrapper = new LambdaQueryWrapper() + .and(StringUtils.hasText(query.keyword()), value -> value + .like(OverseasQuote::getName, query.keyword().trim()) + .or().like(OverseasQuote::getWarehouseName, query.keyword().trim()) + .or().like(OverseasQuote::getProviderName, query.keyword().trim()) + .or().like(OverseasQuote::getVersionCode, query.keyword().trim())) + .eq(warehouseId != null, OverseasQuote::getWarehouseId, warehouseId) + .like(StringUtils.hasText(query.providerName()), OverseasQuote::getProviderName, + StringUtils.hasText(query.providerName()) ? query.providerName().trim() : null) + .eq(StringUtils.hasText(query.currency()), OverseasQuote::getCurrency, + StringUtils.hasText(query.currency()) ? query.currency().trim().toUpperCase(Locale.ROOT) : null) + .eq(status != null, OverseasQuote::getStatus, status) + .orderByDesc(OverseasQuote::getUpdatedAt).orderByDesc(OverseasQuote::getId); + Page page = quotes.selectPage(Page.of(query.page(), query.size()), wrapper); + return new PageResult<>(page.getRecords().stream().map(this::summary).toList(), page.getCurrent(), + page.getSize(), page.getTotal(), page.getPages()); + } + + public OverseasQuoteResponses.Detail detail(long id) { + OverseasQuote quote = required(id); + List feeRows = fees.selectList(new LambdaQueryWrapper() + .eq(OverseasQuoteFee::getQuoteId, id).orderByAsc(OverseasQuoteFee::getSequenceNo)) + .stream().map(value -> new OverseasQuoteResponses.Fee(value.getItem(), value.getBillingUnit(), + value.getUnitPrice(), value.getMinimumCharge(), value.getRemark())).toList(); + List transitRows = transitPrices.selectList( + new LambdaQueryWrapper().eq(OverseasQuoteTransitPrice::getQuoteId, id) + .orderByAsc(OverseasQuoteTransitPrice::getSequenceNo)) + .stream().map(value -> new OverseasQuoteResponses.TransitPrice(value.getFbaWarehouseCode(), + value.getDeliveryMethod(), value.getEstimatedMiles(), value.getPrice(), + value.getMinimumCharge(), value.getRemark())).toList(); + return new OverseasQuoteResponses.Detail(summary(quote), feeRows, transitRows, + quote.getCreatedBy(), quote.getCreatedAt()); + } + + public List warehouseOptions(boolean refresh) { + return refresh ? warehouses.refresh() : warehouses.options(); + } + + @Transactional + public OverseasQuoteResponses.Detail create(String operator, OverseasQuoteRequests.Create request) { + validateDates(request.effectiveStart(), request.effectiveEnd()); + long warehouseId = parseId(request.warehouseId()); + var warehouse = warehouses.required(warehouseId); + LocalDateTime now = LocalDateTime.now(); + OverseasQuote quote = new OverseasQuote(); + quote.setId(IdWorker.getId()); quote.setName(request.name().trim()); quote.setWarehouseId(warehouseId); + quote.setWarehouseCode(warehouse.warehouseCode()); quote.setWarehouseName(warehouse.warehouseName()); + quote.setCountryCode(warehouse.countryCode()); quote.setProviderName(request.providerName().trim()); + quote.setCurrency(currency(request.currency())); quote.setEffectiveStart(request.effectiveStart()); + quote.setEffectiveEnd(request.effectiveEnd()); quote.setVersionCode(request.versionCode().trim()); + quote.setStatus(INACTIVE); quote.setRemark(trim(request.remark())); quote.setRecordVersion(0L); + quote.setCreatedBy(operator); quote.setUpdatedBy(operator); quote.setCreatedAt(now); quote.setUpdatedAt(now); + try { quotes.insert(quote); } + catch (DuplicateKeyException exception) { throw conflict("同一合作仓、报价方和版本号已存在"); } + replaceChildren(quote.getId(), request.warehouseFees(), request.transitPrices()); + return detail(quote.getId()); + } + + @Transactional + public OverseasQuoteResponses.Detail update(long id, String operator, OverseasQuoteRequests.Update request) { + validateDates(request.effectiveStart(), request.effectiveEnd()); + OverseasQuote current = locked(id); + requireVersion(current, request.recordVersion()); + if (ACTIVE.equals(current.getStatus())) throw conflict("启用中的报价不能直接编辑,请先停用"); + int updated = quotes.update(null, new LambdaUpdateWrapper() + .eq(OverseasQuote::getId, id).eq(OverseasQuote::getRecordVersion, request.recordVersion()) + .set(OverseasQuote::getName, request.name().trim()) + .set(OverseasQuote::getProviderName, request.providerName().trim()) + .set(OverseasQuote::getCurrency, currency(request.currency())) + .set(OverseasQuote::getEffectiveStart, request.effectiveStart()) + .set(OverseasQuote::getEffectiveEnd, request.effectiveEnd()) + .set(OverseasQuote::getRemark, trim(request.remark())) + .set(OverseasQuote::getUpdatedBy, operator).set(OverseasQuote::getUpdatedAt, LocalDateTime.now()) + .setSql("record_version = record_version + 1")); + if (updated != 1) throw conflict("报价已被其他操作修改,请刷新后重试"); + replaceChildren(id, request.warehouseFees(), request.transitPrices()); + return detail(id); + } + + @Transactional + public OverseasQuoteResponses.Detail changeStatus(long id, String operator, + OverseasQuoteRequests.ChangeStatus request) { + OverseasQuote current = locked(id); + requireVersion(current, request.recordVersion()); + String status = normalizeStatus(request.status()); + if (status.equals(current.getStatus())) return detail(id); + if (ACTIVE.equals(status) && quotes.countActiveOverlap(current.getWarehouseId(), current.getProviderName(), + current.getCurrency(), current.getEffectiveStart(), current.getEffectiveEnd(), id) > 0) { + throw conflict("同一合作仓、报价方和币种存在生效区间重叠的启用报价"); + } + int updated = quotes.update(null, new LambdaUpdateWrapper() + .eq(OverseasQuote::getId, id).eq(OverseasQuote::getRecordVersion, request.recordVersion()) + .set(OverseasQuote::getStatus, status).set(OverseasQuote::getUpdatedBy, operator) + .set(OverseasQuote::getUpdatedAt, LocalDateTime.now()) + .setSql("record_version = record_version + 1")); + if (updated != 1) throw conflict("报价已被其他操作修改,请刷新后重试"); + return detail(id); + } + + public byte[] exportCsv(OverseasQuoteRequests.Query query) { + OverseasQuoteRequests.Query exportQuery = new OverseasQuoteRequests.Query(1, 200, query.keyword(), + query.warehouseId(), query.providerName(), query.currency(), query.status()); + StringBuilder csv = new StringBuilder("\uFEFF报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n"); + for (var row : page(exportQuery).records()) { + csv.append(cell(row.id())).append(',').append(cell(row.name())).append(',') + .append(cell(row.warehouseCode())).append(',').append(cell(row.warehouseName())).append(',') + .append(cell(row.providerName())).append(',').append(cell(row.currency())).append(',') + .append(row.effectiveStart()).append(',').append(row.effectiveEnd()).append(',') + .append(cell(row.versionCode())).append(',').append(row.status()).append(',') + .append(row.updatedAt()).append("\r\n"); + } + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + private void replaceChildren(long quoteId, List feeRows, + List transitRows) { + fees.delete(new LambdaQueryWrapper().eq(OverseasQuoteFee::getQuoteId, quoteId)); + transitPrices.delete(new LambdaQueryWrapper() + .eq(OverseasQuoteTransitPrice::getQuoteId, quoteId)); + int sequence = 1; + for (var row : feeRows) { + OverseasQuoteFee value = new OverseasQuoteFee(); value.setId(IdWorker.getId()); value.setQuoteId(quoteId); + value.setSequenceNo(sequence++); value.setItem(row.item().trim()); + value.setBillingUnit(row.billingUnit().trim()); value.setUnitPrice(row.unitPrice()); + value.setMinimumCharge(zeroIfNull(row.minimumCharge())); value.setRemark(trim(row.remark())); + fees.insert(value); + } + sequence = 1; + for (var row : transitRows == null ? List.of() : transitRows) { + OverseasQuoteTransitPrice value = new OverseasQuoteTransitPrice(); value.setId(IdWorker.getId()); + value.setQuoteId(quoteId); value.setSequenceNo(sequence++); + value.setFbaWarehouseCode(row.fbaWarehouseCode().trim().toUpperCase(Locale.ROOT)); + value.setDeliveryMethod(row.deliveryMethod().trim().toUpperCase(Locale.ROOT)); + value.setEstimatedMiles(zeroIfNull(row.estimatedMiles())); value.setPrice(row.price()); + value.setMinimumCharge(zeroIfNull(row.minimumCharge())); value.setRemark(trim(row.remark())); + transitPrices.insert(value); + } + } + + private OverseasQuoteResponses.Summary summary(OverseasQuote value) { + return new OverseasQuoteResponses.Summary(value.getId().toString(), value.getName(), + value.getWarehouseId().toString(), value.getWarehouseCode(), value.getWarehouseName(), + value.getCountryCode(), value.getProviderName(), value.getCurrency(), value.getEffectiveStart(), + value.getEffectiveEnd(), value.getVersionCode(), value.getStatus(), version(value.getRecordVersion()), + value.getUpdatedBy(), value.getUpdatedAt(), value.getRemark()); + } + + private OverseasQuote required(long id) { + OverseasQuote value = quotes.selectById(id); + if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "海外仓报价不存在"); + return value; + } + private OverseasQuote locked(long id) { + OverseasQuote value = quotes.lockById(id); + if (value == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "海外仓报价不存在"); + return value; + } + private void requireVersion(OverseasQuote value, long expected) { + if (version(value.getRecordVersion()) != expected) throw conflict("报价已被其他操作修改,请刷新后重试"); + } + private void validateDates(java.time.LocalDate start, java.time.LocalDate end) { + if (end.isBefore(start)) throw badRequest("报价结束日期不能早于开始日期"); + } + private Long parseOptionalId(String value) { return StringUtils.hasText(value) ? parseId(value) : null; } + private long parseId(String value) { + try { long id = Long.parseLong(value.trim()); if (id <= 0) throw new NumberFormatException(); return id; } + catch (NumberFormatException exception) { throw badRequest("warehouseId 无效"); } + } + private String normalizeOptionalStatus(String value) { return StringUtils.hasText(value) ? normalizeStatus(value) : null; } + private String normalizeStatus(String value) { + String status = value.trim().toUpperCase(Locale.ROOT); + if (!ACTIVE.equals(status) && !INACTIVE.equals(status)) throw badRequest("报价状态只支持 ACTIVE/INACTIVE"); + return status; + } + private String currency(String value) { + String currency = value.trim().toUpperCase(Locale.ROOT); + if (currency.length() != 3) throw badRequest("币种必须为三位代码"); + return currency; + } + private static long version(Long value) { return value == null ? 0L : value; } + private static BigDecimal zeroIfNull(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; } + private static ResponseStatusException badRequest(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/masterdata/quote/service/PartnerWarehouseDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java new file mode 100644 index 000000000..de59cd12b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.masterdata.quote.service; + +import com.baiyue.lastleg.masterdata.quote.client.DomesticPartnerWarehouseClient; +import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +/** Single-key immutable snapshot cache for active domestic overseas-partner warehouses. */ +@Component +public class PartnerWarehouseDirectory { + @Resource private DomesticPartnerWarehouseClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.clients.domestic-warehouse.cache-ttl:30m}") private Duration ttl; + private Clock clock = Clock.systemUTC(); + private volatile Snapshot snapshot; + + public List options() { + return options(false); + } + + /** Bypasses a still-valid positive snapshot after CN partner master data changes. */ + public List refresh() { + return options(true); + } + + private List options(boolean forceRefresh) { + Snapshot current = snapshot; + Instant now = clock.instant(); + if (!forceRefresh && current != null && now.isBefore(current.expiresAt())) return current.options(); + synchronized (this) { + current = snapshot; + now = clock.instant(); + if (!forceRefresh && current != null && now.isBefore(current.expiresAt())) return current.options(); + var response = client.activePartners(internalToken); + if (response == null || !response.successful() || response.data() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "国内海外合作仓候选暂不可用"); + } + List options = response.data().stream() + .filter(this::valid) + .map(value -> new OverseasQuoteResponses.PartnerWarehouseOption(value.id().toString(), + value.warehouseCode().trim(), value.warehouseName().trim(), + value.countryCode().trim().toUpperCase(java.util.Locale.ROOT))) + .toList(); + // An empty directory can be a short-lived cross-region visibility gap after a partner + // warehouse is created. Do not preserve that absence for the normal 30-minute TTL. + if (options.isEmpty()) { + snapshot = null; + return List.of(); + } + snapshot = new Snapshot(options, now.plus(ttl)); + return snapshot.options(); + } + } + + public OverseasQuoteResponses.PartnerWarehouseOption required(long warehouseId) { + return options().stream().filter(value -> Long.toString(warehouseId).equals(value.warehouseId())) + .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "所选海外合作仓不存在或已停用")); + } + + private boolean valid(DomesticPartnerWarehouseClient.Option value) { + return value != null && value.id() != null + && StringUtils.hasText(value.warehouseCode()) + && StringUtils.hasText(value.warehouseName()) + && StringUtils.hasText(value.countryCode()) + && value.countryCode().trim().matches("(?i)[A-Z]{2}"); + } + + private record Snapshot(List options, Instant expiresAt) { + private Snapshot { options = List.copyOf(options); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java new file mode 100644 index 000000000..f2d570a9a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.function.Supplier; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles") +@Tag(name = "Vehicle master data / 车辆主数据") +public class PlatformVehicleController { + private final VehicleApplicationService service; + + public PlatformVehicleController(VehicleApplicationService service) { + this.service = service; + } + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") + @Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list") + public ApiResponse> page(@Valid VehicleModels.Query query) { + return execute(() -> ApiResponse.success(service.page(query))); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") + @Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history") + public ApiResponse detail(@PathVariable long id) { + return execute(() -> ApiResponse.success(service.detail(id))); + } + + @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody VehicleModels.Create request) { + return execute(() -> ApiResponse.success(service.create(operator(user), request))); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id, + @Valid @RequestBody VehicleModels.Update request) { + return execute(() -> ApiResponse.success(service.update(id, operator(user), request))); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id, + @Valid @RequestBody VehicleModels.ChangeStatus request) { + return execute(() -> ApiResponse.success(service.changeStatus(id, operator(user), request))); + } + + @GetMapping(value = "/export", produces = "text/csv") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')") + public ResponseEntity export(@Valid VehicleModels.Query query) { + return execute(() -> ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query))); + } + + private String operator(AuthenticatedUser user) { + if (user == null) return "UNKNOWN"; + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private T execute(Supplier action) { + try { + return action.get(); + } catch (VehicleFailure failure) { + HttpStatus status = switch (failure.kind()) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; + case NOT_FOUND -> HttpStatus.NOT_FOUND; + case CONFLICT -> HttpStatus.CONFLICT; + }; + throw new ResponseStatusException(status, failure.getMessage(), failure); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java new file mode 100644 index 000000000..10a96df7f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; + +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 VehicleMapper extends BaseMapper { + @Select("SELECT * FROM ll_vehicle WHERE id=#{id} LIMIT 1 FOR UPDATE") + VehicleRecord lockById(@Param("id") long id); +} 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 new file mode 100644 index 000000000..cb5252cf7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; + +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.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Optional; + +@Repository +public class VehiclePersistenceAdapter implements VehicleRepository { + private final VehicleMapper vehicles; + + public VehiclePersistenceAdapter(VehicleMapper vehicles) { + this.vehicles = vehicles; + } + + @Override + public PageSlice page(SearchCriteria criteria) { + Page page = vehicles.selectPage(Page.of(criteria.page(), criteria.size()), wrapper(criteria)); + return new PageSlice(page.getRecords().stream().map(this::snapshot).toList(), page.getCurrent(), + page.getSize(), page.getTotal(), page.getPages()); + } + + @Override + public List list(SearchCriteria criteria) { + return vehicles.selectList(wrapper(criteria)).stream().map(this::snapshot).toList(); + } + + @Override + public Optional findById(long id) { + return Optional.ofNullable(vehicles.selectById(id)).map(this::snapshot); + } + + @Override + public Optional lockById(long id) { + return Optional.ofNullable(vehicles.lockById(id)).map(this::snapshot); + } + + @Override + public long create(NewVehicle vehicle) { + VehicleRecord record = new VehicleRecord(); + record.setId(IdWorker.getId()); + record.setPlateNumber(vehicle.plateNumber()); + record.setModel(vehicle.model()); + record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km()); + record.setMileageKm(vehicle.mileageKm()); + record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany()); + record.setPhone(vehicle.phone()); + record.setRegion(vehicle.region()); + record.setOwnership(vehicle.ownership()); + record.setStatus(vehicle.status()); + record.setRemark(vehicle.remark()); + record.setRecordVersion(0L); + record.setCreatedBy(vehicle.operator()); + record.setUpdatedBy(vehicle.operator()); + record.setCreatedAt(vehicle.createdAt()); + record.setUpdatedAt(vehicle.createdAt()); + try { + vehicles.insert(record); + } catch (DuplicateKeyException exception) { + throw new DuplicatePlateException(exception); + } + return record.getId(); + } + + @Override + public boolean update(long id, long expectedVersion, VehicleChanges changes) { + try { + return vehicles.update(null, new LambdaUpdateWrapper() + .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) + .set(VehicleRecord::getPlateNumber, changes.plateNumber()) + .set(VehicleRecord::getModel, changes.model()) + .set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km()) + .set(VehicleRecord::getMileageKm, changes.mileageKm()) + .set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany()) + .set(VehicleRecord::getPhone, changes.phone()).set(VehicleRecord::getRegion, changes.region()) + .set(VehicleRecord::getOwnership, changes.ownership()).set(VehicleRecord::getRemark, changes.remark()) + .set(VehicleRecord::getUpdatedBy, changes.operator()).set(VehicleRecord::getUpdatedAt, changes.updatedAt()) + .setSql("record_version = record_version + 1")) == 1; + } catch (DuplicateKeyException exception) { + throw new DuplicatePlateException(exception); + } + } + + @Override + public boolean updateStatus(long id, long expectedVersion, String status, String operator, + java.time.LocalDateTime updatedAt) { + return vehicles.update(null, new LambdaUpdateWrapper() + .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) + .set(VehicleRecord::getStatus, status).set(VehicleRecord::getUpdatedBy, operator) + .set(VehicleRecord::getUpdatedAt, updatedAt).setSql("record_version = record_version + 1")) == 1; + } + + private LambdaQueryWrapper wrapper(SearchCriteria criteria) { + return new LambdaQueryWrapper() + .and(StringUtils.hasText(criteria.keyword()), wrapper -> wrapper.like(VehicleRecord::getPlateNumber, criteria.keyword()) + .or().like(VehicleRecord::getModel, criteria.keyword()) + .or().like(VehicleRecord::getDriverOrDispatchCompany, criteria.keyword())) + .eq(criteria.ownership() != null, VehicleRecord::getOwnership, criteria.ownership()) + .eq(criteria.region() != null, VehicleRecord::getRegion, criteria.region()) + .eq(criteria.status() != null, VehicleRecord::getStatus, criteria.status()) + .orderByDesc(VehicleRecord::getUpdatedAt).orderByDesc(VehicleRecord::getId); + } + + private VehicleSnapshot snapshot(VehicleRecord value) { + 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()); + } + + private static long version(Long value) { + return value == null ? 0L : 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 new file mode 100644 index 000000000..9518e94e4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; + +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_vehicle") +public class VehicleRecord { + @TableId(type = IdType.INPUT) private Long id; + private String plateNumber; + private String model; + private BigDecimal fuelConsumptionPer100Km; + private Long mileageKm; + private String driverOrDispatchCompany; + private String phone; + private String region; + private String ownership; + private String status; + private String remark; + 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/masterdata/vehicle/application/VehicleApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java new file mode 100644 index 000000000..79fbb24d1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java @@ -0,0 +1,165 @@ +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.VehicleRepository.DuplicatePlateException; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleChanges; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Set; + +@Service +public class VehicleApplicationService { + private static final Set OWNERSHIPS = Set.of("OWNED", "DISPATCHED"); + private static final Set REGIONS = Set.of("DOMESTIC", "OVERSEAS"); + private static final Set STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED"); + + private final VehicleRepository vehicles; + + public VehicleApplicationService(VehicleRepository vehicles) { + this.vehicles = vehicles; + } + + public PageResult page(VehicleModels.Query query) { + var page = vehicles.page(criteria(query)); + return new PageResult<>(page.records().stream().map(this::response).toList(), page.page(), + page.size(), page.total(), page.pages()); + } + + public VehicleModels.Response detail(long id) { + return response(required(id)); + } + + @Transactional + public VehicleModels.Response create(String operator, VehicleModels.Create request) { + LocalDateTime now = LocalDateTime.now(); + long id; + try { + id = vehicles.create(new NewVehicle( + 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)); + } catch (DuplicatePlateException exception) { + throw conflict("车牌号已存在"); + } + return detail(id); + } + + @Transactional + public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) { + VehicleSnapshot current = locked(id); + requireVersion(current, request.recordVersion()); + if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码"); + 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())); + } catch (DuplicatePlateException exception) { + throw conflict("车牌号已存在"); + } + if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + return detail(id); + } + + @Transactional + public VehicleModels.Response changeStatus(long id, String operator, VehicleModels.ChangeStatus request) { + VehicleSnapshot current = locked(id); + requireVersion(current, request.recordVersion()); + String status = status(request.status()); + if (status.equals(current.status())) return response(current); + boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now()); + if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + return detail(id); + } + + 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(',') + .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(row.updatedAt()).append("\r\n"); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + private SearchCriteria criteria(VehicleModels.Query query) { + return new SearchCriteria(query.page(), query.size(), trim(query.keyword()), + optional(query.ownership(), OWNERSHIPS, "车辆归属"), + optional(query.region(), REGIONS, "车辆区域"), + optional(query.status(), STATUSES, "车辆状态")); + } + + private VehicleSnapshot required(long id) { + return vehicles.findById(id).orElseThrow(VehicleApplicationService::notFound); + } + + private VehicleSnapshot locked(long id) { + return vehicles.lockById(id).orElseThrow(VehicleApplicationService::notFound); + } + + private VehicleModels.Response response(VehicleSnapshot value) { + 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()); + } + + private void requireVersion(VehicleSnapshot value, long expected) { + if (value.recordVersion() != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + } + + private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); } + private static String region(String value) { return required(value, REGIONS, "车辆区域"); } + private static String status(String value) { return required(value, STATUSES, "车辆状态"); } + + private static String optional(String value, Set allowed, String label) { + return StringUtils.hasText(value) ? required(value, allowed, label) : null; + } + + private static String required(String value, Set allowed, String label) { + if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); + return normalized; + } + + private static String requiredText(String value, String label) { + if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); + return value.trim(); + } + + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; } + private static VehicleFailure notFound() { return new VehicleFailure(FailureKind.NOT_FOUND, "车辆档案不存在"); } + private static VehicleFailure badRequest(String message) { return new VehicleFailure(FailureKind.BAD_REQUEST, message); } + private static VehicleFailure conflict(String message) { return new VehicleFailure(FailureKind.CONFLICT, message); } + + public enum FailureKind { BAD_REQUEST, NOT_FOUND, CONFLICT } + + public static final class VehicleFailure extends RuntimeException { + private final FailureKind kind; + + public VehicleFailure(FailureKind kind, String message) { + super(message); + this.kind = kind; + } + + public FailureKind kind() { + return kind; + } + } +} 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 new file mode 100644 index 000000000..36a307164 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.masterdata.vehicle.application.model; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public final class VehicleModels { + private VehicleModels() { } + + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, + @Size(max = 128) String keyword, String ownership, String region, String status) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + } + + public record Create(@NotBlank @Size(max = 64) String plateNumber, + @NotBlank @Size(max = 128) String model, + @NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 2) BigDecimal fuelConsumptionPer100Km, + @NotNull @Min(0) Long mileageKm, + @Size(max = 128) String driverOrDispatchCompany, + @Size(max = 64) String phone, + @NotBlank String region, @NotBlank String ownership, + @NotBlank String status, @Size(max = 1000) String remark) { } + + public record Update(@NotNull @Min(0) Long recordVersion, + @NotBlank @Size(max = 64) String plateNumber, + @NotBlank @Size(max = 128) String model, + @NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 2) BigDecimal fuelConsumptionPer100Km, + @NotNull @Min(0) Long mileageKm, + @Size(max = 128) String driverOrDispatchCompany, + @Size(max = 64) String phone, + @NotBlank String region, @NotBlank String ownership, + @Size(max = 1000) String remark) { } + + 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) { } +} 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 new file mode 100644 index 000000000..31f0b89a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.masterdata.vehicle.application.port.out; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface VehicleRepository { + PageSlice page(SearchCriteria criteria); + + List list(SearchCriteria criteria); + + Optional findById(long id); + + Optional lockById(long id); + + long create(NewVehicle vehicle); + + boolean update(long id, long expectedVersion, VehicleChanges changes); + + boolean updateStatus(long id, long expectedVersion, String status, String operator, LocalDateTime updatedAt); + + record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status) { } + + record PageSlice(List records, long page, long size, long total, long pages) { } + + 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) { } + + record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, + String driverOrDispatchCompany, String phone, String region, String ownership, String remark, + String operator, LocalDateTime updatedAt) { } + + 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) { } + + final class DuplicatePlateException extends RuntimeException { + public DuplicatePlateException(Throwable cause) { + super(cause); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java new file mode 100644 index 000000000..a3e5b145f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.model; + +/** Standard main-process statuses for a last-leg order. */ +public enum LastlegOrderStatus { + PENDING_ASSIGNMENT("待分配"), + PENDING_INBOUND("待入库"), + INBOUND_COMPLETED("入库完成"), + PENDING_DELIVERY("待派送"), + DELIVERING("派送中"), + DELIVERED("派送完成"); + + private final String label; + + LastlegOrderStatus(String label) { + this.label = label; + } + + public String label() { + return label; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java new file mode 100644 index 000000000..4036c2b43 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.model; + +/** Supported business types for a last-leg order. */ +public enum LastlegOrderType { + /** Overseas order created from a completed first-leg loading plan. */ + FIRST_LEG("头程订单"), + /** Customer-initiated relabel order. */ + RELABEL_APPLICATION("换标下单"), + /** Warehouse-to-warehouse transfer order. */ + WAREHOUSE_TRANSFER("仓储转运"), + /** Domestic/local loose pallet order. */ + LOCAL_LOOSEBOARD("本土散板"), + /** Return shipment that also requires relabeling. */ + RETURN_RELABEL("退货换标"), + /** One-click dropship order, retained for existing data and workflows. */ + ONE_CLICK_DROPSHIP("一件代发"); + + private final String label; + + LastlegOrderType(String label) { + this.label = label; + } + + public String label() { + return label; + } + + public static String labelOf(String code) { + if (code == null || code.isBlank()) return code; + try { + return valueOf(code.trim()).label; + } catch (IllegalArgumentException exception) { + return code; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java new file mode 100644 index 000000000..def4fce41 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.palletops.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.palletops.dto.PalletOperationResponses; +import com.baiyue.lastleg.palletops.service.PdaPalletOperationService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/pallet-operations") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaPalletOperationController { + @Resource private PdaPalletOperationService service; + + @PostMapping("/pallets/resolve") + public ApiResponse resolve( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Scan request) { + return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/assemble") + public ApiResponse assemble( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Assemble request) { + return ApiResponse.success(service.assemble(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/disassemble") + public ApiResponse disassemble( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Disassemble request) { + return ApiResponse.success(service.disassemble(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/swap") + public ApiResponse swap( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Swap request) { + return ApiResponse.success(service.swap(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/unbind") + public ApiResponse unbind( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Unbind request) { + return ApiResponse.success(service.unbind(PdaWarehouseIdentity.require(user), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java new file mode 100644 index 000000000..1b804c6ca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.palletops.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class PalletOperationRequests { + private PalletOperationRequests() { } + + public record Scan(@NotBlank @Size(max = 128) String code) { } + public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode, + @PositiveOrZero int expectedVersion) { } + public record MoveLine(@Positive long positionId, @Positive long packages, + @PositiveOrZero long expectedPositionVersion) { } + public record TargetMove(@Valid PalletExpectation target, + @NotEmpty @Size(max = 200) List<@Valid MoveLine> lines) { } + + public record Assemble(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation target, + @NotEmpty @Size(max = 20) List<@Valid PalletExpectation> sources, + @NotEmpty @Size(max = 200) List<@Valid MoveLine> lines, + @Size(max = 500) String reason) { } + + public record Disassemble(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation source, + @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets, + @Size(max = 500) String reason) { } + + public record Swap(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation source, + @Valid PalletExpectation target, + @Size(max = 500) String reason) { } + + public record Unbind(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation source, + @Size(max = 500) String reason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java new file mode 100644 index 000000000..c0cd61ce7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.palletops.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PalletOperationResponses { + private PalletOperationResponses() { } + + public record InventoryLine(String customerId, String customerName, long balanceId, long positionId, + String inboundCode, String skuCode, String productName, + long availablePackages, long holdPackages, long allocatedPackages, + long pickedPackages, long receivingPackages, long positionVersion) { } + public record PalletContent(long palletId, String palletCode, String status, int palletVersion, + long locationId, String locationCode, String destinationWarehouseCode, + long totalPackages, List inventory) { } + public record Operation(long operationId, String operationType, String idempotencyKey, + String status, boolean replayed, LocalDateTime completedAt, + List pallets) { + public Operation asReplay() { + return new Operation(operationId, operationType, idempotencyKey, status, true, completedAt, pallets); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java new file mode 100644 index 000000000..7d4e1a9be --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.palletops.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; + +@Data +@TableName("ll_pda_pallet_operation") +public class PdaPalletOperation { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long overseasWarehouseId; + private String operatorKey; + private String idempotencyKey; + private String operationType; + private String requestHash; + private String status; + private String resultJson; + private String remoteTransitionsJson; + private String lastError; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} 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 new file mode 100644 index 000000000..4b4154ddf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.palletops.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct void initialize() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getPalletPhysical()); + } + + @Override + public PalletReference resolve(String palletCode) { + ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/{code}", palletCode) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Warehouse rejected pallet scan: HTTP " + result.getStatusCode()); + }).body(ResolveEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no pallet"); + return response.data(); + } + + @Override + public LocationReference resolveLocation(long warehouseId, long locationId) { + LocationEnvelope response = client.get().uri(uri -> uri + .path("/internal/v1/warehouse-resource-locks/locations/{locationId}") + .queryParam("warehouseId", warehouseId).build(locationId)) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Warehouse rejected location lookup: HTTP " + result.getStatusCode()); + }).body(LocationEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no location"); + return response.data(); + } + + @Override + public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId, + String operationKey, String reason) { + Map body = new LinkedHashMap<>(); + body.put("warehouseId", warehouseId); body.put("expectedVersion", pallet.version()); + body.put("targetStatus", targetStatus); body.put("targetLocationId", targetLocationId); + body.put("operationKey", operationKey); if (reason != null) body.put("reason", reason); + TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}", pallet.palletId()) + .header("X-Internal-Service-Token", internalToken).body(body).retrieve() + .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"); + return response.data(); + } + + @Override + public void compensate(long warehouseId, Transition transition) { + 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(); + } + + private record ResolveEnvelope(PalletReference data) { } + private record LocationEnvelope(LocationReference data) { } + private record TransitionEnvelope(Transition data) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java new file mode 100644 index 000000000..f00726b97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.palletops.gateway; + +public interface PalletPhysicalGateway { + PalletReference resolve(String palletCode); + LocationReference resolveLocation(long warehouseId, long locationId); + Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId, + String operationKey, String reason); + void compensate(long warehouseId, Transition transition); + + record PalletReference(long palletId, String palletCode, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) { } + record LocationReference(long locationId, String locationCode, String locationType, String status) { } + record Transition(String operationKey, long palletId, String palletCode, long warehouseId, + String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId, + int beforeVersion, int afterVersion, String operationStatus, boolean replayed) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java new file mode 100644 index 000000000..f28f4c05a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.palletops.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.baiyue.lastleg.palletops.model.PalletInventoryRow; +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 PdaPalletOperationMapper extends BaseMapper { + String CONTENT_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," + + "b.inbound_code,b.sku_code,b.product_name_zh,b.product_name_en,b.destination_warehouse_code," + + "p.pallet_id,p.pallet_no,p.batch_no,p.warehouse_location_id,p.warehouse_location_code," + + "p.available_packages,p.hold_packages,p.allocated_packages,p.picked_packages,p.receiving_packages,p.version " + + "FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id "; + + @Select("SELECT * FROM ll_pda_pallet_operation WHERE operator_key=#{operatorKey} " + + "AND idempotency_key=#{idempotencyKey} LIMIT 1") + PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey, + @Param("idempotencyKey") String idempotencyKey); + + @Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.id") + List content(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId); + + @Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.id=#{positionId} " + + "LIMIT 1 FOR UPDATE") + PalletInventoryRow lockPosition(@Param("warehouseId") String warehouseId, + @Param("positionId") long positionId); + + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " + + "AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE") + InventoryPosition lockTarget(@Param("customerId") String customerId, @Param("balanceId") long balanceId, + @Param("palletId") long palletId, @Param("batchNo") String batchNo); + + @Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " + + "FROM ll_inventory_position WHERE pallet_id=#{palletId} AND overseas_warehouse_id=#{warehouseId}") + long palletPackages(@Param("warehouseId") String warehouseId, @Param("palletId") long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java new file mode 100644 index 000000000..98aa0084c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.palletops.model; + +import lombok.Data; + +@Data +public class PalletInventoryRow { + private String customerId; + private String customerName; + private Long balanceId; + private Long positionId; + private String inboundCode; + private String skuCode; + private String productNameZh; + private String productNameEn; + private String destinationWarehouseCode; + private Long palletId; + private String palletNo; + private String batchNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long receivingPackages; + private Long version; +} 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 new file mode 100644 index 000000000..35bcdee74 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -0,0 +1,528 @@ +package com.baiyue.lastleg.palletops.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.palletops.dto.PalletOperationResponses; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.model.PalletInventoryRow; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@Service +public class PdaPalletOperationService { + @Resource private PdaPalletOperationMapper mapper; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryLedgerMapper ledgers; + @Resource private PalletPhysicalGateway physical; + @Resource private PdaWarehouseService history; + @Resource private ObjectMapper objectMapper; + private TransactionTemplate transaction; + private TransactionTemplate newTransaction; + + @Resource + void configureTransactions(PlatformTransactionManager manager) { + transaction = new TransactionTemplate(manager); + DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); + definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + newTransaction = new TransactionTemplate(manager, definition); + } + + public PalletOperationResponses.PalletContent resolve(PdaWarehouseIdentity identity, + PalletOperationRequests.Scan request) { + PalletPhysicalGateway.PalletReference pallet = requirePallet(identity.overseasWarehouseId(), request.code()); + return content(identity.overseasWarehouseId(), pallet); + } + + public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity, + PalletOperationRequests.Assemble request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_ASSEMBLE", + request.idempotencyKey(), request); + if (replay != null) return replay; + Map pallets = resolvePallets(identity.overseasWarehouseId(), + request.target(), request.sources()); + PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode())); + Map sources = new LinkedHashMap<>(); + request.sources().forEach(value -> sources.put(code(value.palletCode()), pallets.get(code(value.palletCode())))); + List moves = plannedLines(identity.overseasWarehouseId(), request.lines(), sources, + target, null); + requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target)); + return execute(identity, "PALLET_ASSEMBLE", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false)); + } + + public PalletOperationResponses.Operation disassemble(PdaWarehouseIdentity identity, + PalletOperationRequests.Disassemble request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_DISASSEMBLE", + request.idempotencyKey(), request); + if (replay != null) return replay; + LinkedHashMap expectations = new LinkedHashMap<>(); + expectations.put(code(request.source().palletCode()), request.source()); + request.targets().forEach(value -> expectations.put(code(value.target().palletCode()), value.target())); + Map pallets = resolvePallets(identity.overseasWarehouseId(), + expectations.values()); + PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); + List moves = new ArrayList<>(); + for (PalletOperationRequests.TargetMove targetMove : request.targets()) { + PalletPhysicalGateway.PalletReference target = pallets.get(code(targetMove.target().palletCode())); + moves.addAll(plannedLines(identity.overseasWarehouseId(), targetMove.lines(), Map.of( + code(request.source().palletCode()), source), target, source)); + } + requireUniqueLines(moves); + List targets = request.targets().stream() + .map(value -> pallets.get(code(value.target().palletCode()))).toList(); + requireCommonDestination(identity.overseasWarehouseId(), moves, targets); + return execute(identity, "PALLET_DISASSEMBLE", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, + targets.stream().map(PalletPhysicalGateway.PalletReference::palletId).collect(java.util.stream.Collectors.toSet()), + false)); + } + + public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity, + PalletOperationRequests.Swap request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_SWAP", + request.idempotencyKey(), request); + if (replay != null) return replay; + Map pallets = resolvePallets(identity.overseasWarehouseId(), + request.source(), List.of(request.target())); + PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); + PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode())); + if (!mapper.content(String.valueOf(identity.overseasWarehouseId()), target.palletId()).isEmpty()) { + throw conflict("换托目标托盘必须为空"); + } + List rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId()); + List moves = rows.stream().map(row -> { + requireMovable(row, value(row.getAvailablePackages())); + return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()), + source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(), + locationCode(identity.overseasWarehouseId(), target.locationId())); + }).toList(); + return execute(identity, "PALLET_SWAP", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false)); + } + + public PalletOperationResponses.Operation unbind(PdaWarehouseIdentity identity, + PalletOperationRequests.Unbind request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_UNBIND", + request.idempotencyKey(), request); + if (replay != null) return replay; + Map pallets = resolvePallets(identity.overseasWarehouseId(), + List.of(request.source())); + PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); + List rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId()); + List moves = rows.stream().map(row -> { + requireMovable(row, value(row.getAvailablePackages())); + return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()), + source.palletId(), source.palletCode(), 0L, "UNBOUND", source.locationId(), + locationCode(identity.overseasWarehouseId(), source.locationId())); + }).toList(); + return execute(identity, "PALLET_UNBIND", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true)); + } + + private PalletOperationResponses.Operation execute(PdaWarehouseIdentity identity, String type, String key, + Object request, String reason, Plan plan) { + String normalizedKey = required(key, "幂等键不能为空"); + String requestHash = hash(request); + PdaPalletOperation operation = begin(identity, type, normalizedKey, requestHash); + if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay(); + + 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)); + if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果"); + return result; + } catch (RuntimeException failure) { + boolean compensated = compensate(identity.overseasWarehouseId(), transitions); + fail(operation.getId(), failure, compensated); + throw failure; + } + } + + private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation, + Plan plan, String key, String requestHash, String reason) { + Set seen = new HashSet<>(); + LinkedHashSet customers = new LinkedHashSet<>(); + for (PlannedMove move : plan.moves()) { + if (!seen.add(move.positionId())) throw conflict("同一库存行不能在一次托盘作业中重复移动"); + PalletInventoryRow locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), + move.positionId()); + if (locked == null || !Objects.equals(locked.getPalletId(), move.sourcePalletId())) { + throw conflict("库存所在托盘已变化,请重新扫码"); + } + if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码"); + requireMovable(locked, move.packages()); + customers.add(locked.getCustomerId()); + moveInventory(identity, operation, locked, move, key, reason); + } + for (PalletChange change : plan.changes()) { + long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId()); + boolean shouldBeEmpty = "IDLE".equals(change.targetStatus()); + if (shouldBeEmpty != (actual == 0)) { + throw conflict("库存与实物托盘状态不一致,作业已回滚"); + } + } + + List contents = plan.pallets().values().stream() + .sorted(Comparator.comparingLong(PalletPhysicalGateway.PalletReference::palletId)) + .map(pallet -> content(identity.overseasWarehouseId(), physical.resolve(pallet.palletCode()))) + .toList(); + PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(), + operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents); + operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); operation.setLastError(null); + operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation); + history.record(identity.overseasWarehouseId(), customers.size() == 1 ? customers.iterator().next() : null, + identity.operatorKey(), operation.getOperationType(), businessKey(plan), "COMPLETED", key, requestHash, + "托盘作业完成,移动库存行 " + plan.moves().size() + " 条"); + return result; + } + + private void moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation, + PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) { + InventoryPosition source = positions.selectById(sourceRow.getPositionId()); + if (source == null) throw conflict("库存行不存在"); + long sourceBefore = value(source.getAvailablePackages()); + source.setAvailablePackages(sourceBefore - move.packages()); + source.setStatus(positionPackages(source) == 0 ? "EMPTY" : "OCCUPIED"); + source.setVersion(value(source.getVersion()) + 1); source.setUpdatedAt(LocalDateTime.now()); + positions.updateById(source); + + InventoryPosition target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(), + move.targetPalletId(), source.getBatchNo()); + if (target == null) { + target = new InventoryPosition(); target.setId(IdWorker.getId()); + target.setCustomerId(source.getCustomerId()); target.setBalanceId(source.getBalanceId()); + target.setOverseasWarehouseId(source.getOverseasWarehouseId()); + target.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); + target.setPalletId(move.targetPalletId()); target.setPalletNo(move.targetPalletCode()); + target.setBatchNo(source.getBatchNo()); target.setWarehouseLocationId(move.targetLocationId()); + target.setWarehouseLocationCode(move.targetLocationCode()); + target.setAvailablePackages(0L); target.setHoldPackages(0L); target.setAllocatedPackages(0L); + target.setPickedPackages(0L); target.setReceivingPackages(0L); target.setStatus("OCCUPIED"); + target.setVersion(0L); target.setCreatedAt(LocalDateTime.now()); target.setUpdatedAt(target.getCreatedAt()); + try { positions.insert(target); } + catch (DuplicateKeyException duplicate) { + target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(), move.targetPalletId(), source.getBatchNo()); + if (target == null) throw duplicate; + } + } + long targetBefore = value(target.getAvailablePackages()); + target.setAvailablePackages(targetBefore + move.packages()); target.setStatus("OCCUPIED"); + target.setWarehouseLocationId(move.targetLocationId()); + target.setWarehouseLocationCode(move.targetLocationCode()); + target.setVersion(value(target.getVersion()) + 1); target.setUpdatedAt(LocalDateTime.now()); + positions.updateById(target); + + InventoryBalance balance = balances.selectById(source.getBalanceId()); + if (balance == null) throw new IllegalStateException("库存余额不存在"); + ledger(identity, operation, balance, source, key + ":" + source.getId() + ":OUT", + sourceBefore, -move.packages(), sourceBefore - move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason); + ledger(identity, operation, balance, target, key + ":" + source.getId() + ":IN", + targetBefore, move.packages(), targetBefore + move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason); + } + + private void ledger(PdaWarehouseIdentity identity, PdaPalletOperation operation, InventoryBalance balance, + InventoryPosition position, String idempotencyKey, long before, long delta, long after, + String sourcePallet, String targetPallet, String reason) { + InventoryLedger ledger = new InventoryLedger(); ledger.setId(IdWorker.getId()); + ledger.setCustomerId(balance.getCustomerId()); ledger.setBalanceId(balance.getId()); + ledger.setPositionId(position.getId()); ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + ledger.setInboundOrderId(balance.getInboundOrderId()); ledger.setPalletId(position.getPalletId()); + ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo()); + ledger.setSkuCode(balance.getSkuCode()); ledger.setWarehouseLocationCode(position.getWarehouseLocationCode()); + ledger.setBusinessType(operation.getOperationType()); ledger.setBusinessOrderId(operation.getId()); + ledger.setBusinessOrderNo(String.valueOf(operation.getId())); ledger.setInboundCode(balance.getInboundCode()); + ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setIdempotencyKey(idempotencyKey); + ledger.setStatusFrom("OCCUPIED"); + ledger.setStatusTo("UNBOUND".equals(targetPallet) ? "UNBOUND" : "OCCUPIED"); + ledger.setBeforePackages(before); ledger.setDeltaPackages(delta); ledger.setAfterPackages(after); + ledger.setOperatorId(identity.operatorKey()); ledger.setRemark(trim(reason)); + ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger); + } + + private Plan plan(long warehouseId, Map pallets, + List moves, Set targets, boolean unbind) { + Map movedBySource = new HashMap<>(); + moves.forEach(move -> movedBySource.merge(move.sourcePalletId(), move.packages(), Long::sum)); + List changes = new ArrayList<>(); + for (PalletPhysicalGateway.PalletReference pallet : pallets.values()) { + long total = mapper.palletPackages(String.valueOf(warehouseId), pallet.palletId()); + long moved = movedBySource.getOrDefault(pallet.palletId(), 0L); + String targetStatus = targets.contains(pallet.palletId()) ? "OCCUPIED" + : moved > 0 && moved == total ? "IDLE" : pallet.status(); + if ("SCRAPPED".equals(targetStatus)) throw conflict("已报废托盘不能执行作业"); + changes.add(new PalletChange(pallet, targetStatus, pallet.locationId())); + } + if (unbind && changes.stream().noneMatch(change -> "IDLE".equals(change.targetStatus()))) { + throw conflict("解绑必须清空来源托盘"); + } + changes.sort(Comparator.comparingLong(change -> change.pallet().palletId())); + return new Plan(pallets, moves, changes); + } + + private List plannedLines(long warehouseId, List lines, + Map sources, + PalletPhysicalGateway.PalletReference target, + PalletPhysicalGateway.PalletReference requiredSource) { + Map available = new HashMap<>(); + for (PalletPhysicalGateway.PalletReference source : sources.values()) { + for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), source.palletId())) { + available.put(row.getPositionId(), row); + } + } + List result = new ArrayList<>(); + for (PalletOperationRequests.MoveLine line : lines) { + PalletInventoryRow row = available.get(line.positionId()); + if (row == null || requiredSource != null && !Objects.equals(row.getPalletId(), requiredSource.palletId())) { + throw conflict("库存行不属于已扫描的来源托盘"); + } + if (value(row.getVersion()) != line.expectedPositionVersion()) throw conflict("库存版本已变化,请重新扫码"); + requireMovable(row, line.packages()); + result.add(new PlannedMove(row.getPositionId(), line.packages(), line.expectedPositionVersion(), + row.getPalletId(), row.getPalletNo(), target.palletId(), target.palletCode(), target.locationId(), + locationCode(warehouseId, target.locationId()))); + } + requireUniqueLines(result); + return result; + } + + private void requireCommonDestination(long warehouseId, List moves, + List targets) { + LinkedHashSet destinations = new LinkedHashSet<>(); + for (PlannedMove move : moves) { + PalletInventoryRow row = mapper.content(String.valueOf(warehouseId), move.sourcePalletId()).stream() + .filter(value -> Objects.equals(value.getPositionId(), move.positionId())).findFirst() + .orElseThrow(() -> conflict("库存行不存在")); + destinations.add(text(row.getDestinationWarehouseCode())); + } + for (PalletPhysicalGateway.PalletReference target : targets) { + mapper.content(String.valueOf(warehouseId), target.palletId()).stream() + .map(PalletInventoryRow::getDestinationWarehouseCode).map(PdaPalletOperationService::text) + .forEach(destinations::add); + } + if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘"); + } + + private Map resolvePallets(long warehouseId, + PalletOperationRequests.PalletExpectation first, List rest) { + List values = new ArrayList<>(); values.add(first); values.addAll(rest); + return resolvePallets(warehouseId, values); + } + private Map resolvePallets(long warehouseId, + java.util.Collection values) { + LinkedHashMap result = new LinkedHashMap<>(); + for (PalletOperationRequests.PalletExpectation expected : values) { + String code = code(expected.palletCode()); + if (result.containsKey(code)) throw badRequest("一次作业不能重复提交同一托盘"); + PalletPhysicalGateway.PalletReference pallet = requirePallet(warehouseId, code); + if (pallet.version() != expected.expectedVersion()) throw conflict("托盘版本已变化,请重新扫码"); + result.put(code, pallet); + } + return result; + } + + private PalletPhysicalGateway.PalletReference requirePallet(long warehouseId, String code) { + PalletPhysicalGateway.PalletReference pallet = physical.resolve(code(code)); + if (pallet.warehouseId() != warehouseId) throw conflict("托盘不属于 Token 冻结的海外仓"); + if ("SCRAPPED".equals(pallet.status())) throw conflict("已报废托盘不能执行作业"); + return pallet; + } + + private List nonEmptyContent(long warehouseId, long palletId) { + List values = mapper.content(String.valueOf(warehouseId), palletId); + if (values.isEmpty()) throw conflict("来源托盘没有可操作库存"); + return values; + } + + private PalletOperationResponses.PalletContent content(long warehouseId, + PalletPhysicalGateway.PalletReference pallet) { + List rows = mapper.content(String.valueOf(warehouseId), pallet.palletId()); + LinkedHashSet destinations = new LinkedHashSet<>(); + List inventory = rows.stream().map(row -> { + destinations.add(text(row.getDestinationWarehouseCode())); + return new PalletOperationResponses.InventoryLine(row.getCustomerId(), row.getCustomerName(), + value(row.getBalanceId()), value(row.getPositionId()), row.getInboundCode(), row.getSkuCode(), + first(row.getProductNameZh(), row.getProductNameEn()), value(row.getAvailablePackages()), + value(row.getHoldPackages()), value(row.getAllocatedPackages()), value(row.getPickedPackages()), + value(row.getReceivingPackages()), value(row.getVersion())); + }).toList(); + long total = rows.stream().mapToLong(PdaPalletOperationService::rowPackages).sum(); + String locationCode = locationCode(warehouseId, pallet.locationId()); + return new PalletOperationResponses.PalletContent(pallet.palletId(), pallet.palletCode(), pallet.status(), + pallet.version(), pallet.locationId(), locationCode, destinations.size() == 1 ? destinations.iterator().next() : null, + total, inventory); + } + + private PdaPalletOperation begin(PdaWarehouseIdentity identity, String type, String key, String requestHash) { + PdaPalletOperation existing = mapper.findOperation(identity.operatorKey(), key); + if (existing != null) return validateExisting(existing, type, requestHash); + try { + return newTransaction.execute(status -> { + PdaPalletOperation operation = new PdaPalletOperation(); operation.setId(IdWorker.getId()); + operation.setOverseasWarehouseId(identity.overseasWarehouseId()); + operation.setOperatorKey(identity.operatorKey()); operation.setIdempotencyKey(key); + operation.setOperationType(type); operation.setRequestHash(requestHash); operation.setStatus("PROCESSING"); + operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt()); + mapper.insert(operation); return operation; + }); + } catch (DuplicateKeyException duplicate) { + PdaPalletOperation winner = mapper.findOperation(identity.operatorKey(), key); + if (winner != null) return validateExisting(winner, type, requestHash); + throw duplicate; + } + } + + private PdaPalletOperation validateExisting(PdaPalletOperation value, String type, String requestHash) { + if (!type.equals(value.getOperationType()) || !requestHash.equals(value.getRequestHash())) { + throw conflict("幂等键已被不同托盘作业使用"); + } + if (!"COMPLETED".equals(value.getStatus())) { + throw conflict("同一托盘作业正在处理或需要人工恢复"); + } + return value; + } + + private PalletOperationResponses.Operation replayIfPresent(PdaWarehouseIdentity identity, String type, + String key, Object request) { + String normalizedKey = required(key, "幂等键不能为空"); + PdaPalletOperation existing = mapper.findOperation(identity.operatorKey(), normalizedKey); + if (existing == null) return null; + validateExisting(existing, type, hash(request)); + return read(existing).asReplay(); + } + + private void saveTransitions(long operationId, List transitions) { + newTransaction.executeWithoutResult(status -> { + PdaPalletOperation operation = mapper.selectById(operationId); + operation.setRemoteTransitionsJson(write(transitions)); operation.setUpdatedAt(LocalDateTime.now()); + mapper.updateById(operation); + }); + } + private void fail(long operationId, RuntimeException failure, boolean compensated) { + newTransaction.executeWithoutResult(status -> { + PdaPalletOperation operation = mapper.selectById(operationId); + if (operation == null || "COMPLETED".equals(operation.getStatus())) return; + operation.setStatus(compensated ? "FAILED_COMPENSATED" : "COMPENSATION_REQUIRED"); + operation.setLastError(limit(failure.getMessage(), 2000)); operation.setUpdatedAt(LocalDateTime.now()); + mapper.updateById(operation); + }); + } + private boolean compensate(long warehouseId, List transitions) { + boolean success = true; + for (int i = transitions.size() - 1; i >= 0; i--) { + try { physical.compensate(warehouseId, transitions.get(i)); } + catch (RuntimeException ignored) { success = false; } + } + return success; + } + + private PalletOperationResponses.Operation read(PdaPalletOperation operation) { + try { return objectMapper.readValue(operation.getResultJson(), PalletOperationResponses.Operation.class); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取托盘作业幂等结果", exception); } + } + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法序列化托盘作业", exception); } + } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private static String transitionKey(String operator, String key, long palletId) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest((operator + "|" + key + "|" + palletId).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private String locationCode(long warehouseId, long locationId) { + PalletPhysicalGateway.LocationReference location = physical.resolveLocation(warehouseId, locationId); + if (location == null || location.locationId() != locationId || "INACTIVE".equals(location.status())) { + throw conflict("托盘库位不存在或已停用"); + } + return location.locationCode(); + } + private static void requireMovable(PalletInventoryRow row, long packages) { + if (packages <= 0 || value(row.getAvailablePackages()) < packages) throw conflict("可用库存箱数不足"); + if (value(row.getHoldPackages()) > 0 || value(row.getAllocatedPackages()) > 0 + || value(row.getPickedPackages()) > 0 || value(row.getReceivingPackages()) > 0) { + throw conflict("冻结、已分配、已拣或收货中的库存不能执行托盘作业"); + } + } + private static void requireUniqueLines(List moves) { + Set ids = new HashSet<>(); + if (moves.stream().anyMatch(move -> !ids.add(move.positionId()))) throw badRequest("库存行不能重复提交"); + } + private static long rowPackages(PalletInventoryRow row) { + return value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages()) + + value(row.getPickedPackages()) + value(row.getReceivingPackages()); + } + private static long positionPackages(InventoryPosition value) { + return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + value(value.getAllocatedPackages()) + + value(value.getPickedPackages()) + value(value.getReceivingPackages()); + } + private static String businessKey(Plan plan) { + return plan.pallets().values().stream().map(PalletPhysicalGateway.PalletReference::palletCode) + .sorted().collect(java.util.stream.Collectors.joining("->")); + } + private static String required(String value, String message) { String result = trim(value); if (result == null) throw badRequest(message); return result; } + private static String code(String value) { return required(value, "托盘码不能为空").toUpperCase(); } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String text(String value) { return value == null ? "" : value.trim(); } + private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; } + private static String limit(String value, int size) { if (value == null) return null; return value.length() <= size ? value : value.substring(0, size); } + private static long value(Long value) { return value == null ? 0L : value; } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + + private record PlannedMove(long positionId, long packages, long expectedVersion, + long sourcePalletId, String sourcePalletCode, + long targetPalletId, String targetPalletCode, long targetLocationId, + String targetLocationCode) { } + private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus, + long targetLocationId) { } + private record Plan(Map pallets, + List moves, List changes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfiguration.java new file mode 100644 index 000000000..56392c5f7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfiguration.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.pda; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +/** Registers mapper packages used by the overseas PDA warehouse operation workflows. */ +@Configuration +@MapperScan(basePackages = { + "com.baiyue.lastleg.palletops.mapper", + "com.baiyue.lastleg.pda.move.mapper", + "com.baiyue.lastleg.pda.stocktake.mapper", + "com.baiyue.lastleg.pda.warehouse.mapper" +}) +public class PdaOperationsMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java new file mode 100644 index 000000000..6bfdf88b7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.pda.move.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse; +import com.baiyue.lastleg.pda.move.service.PdaInventoryMoveService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/inventory-moves") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaInventoryMoveController { + @Resource private PdaInventoryMoveService service; + + @PostMapping + public ApiResponse move(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaInventoryMoveRequest request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.move(identity.overseasWarehouseId(), identity.operatorKey(), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java new file mode 100644 index 000000000..0e79b961d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.pda.move.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public record PdaInventoryMoveRequest( + @NotBlank @Size(max = 128) String sourcePalletCode, + @NotBlank @Size(max = 128) String targetLocationCode, + @PositiveOrZero int expectedPalletVersion, + @NotBlank @Size(max = 128) String idempotencyKey, + @NotBlank @Size(max = 500) String reason +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java new file mode 100644 index 000000000..40f55affc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.pda.move.dto; + +import java.time.LocalDateTime; + +public record PdaInventoryMoveResponse( + String moveNo, + long palletId, + String palletCode, + long sourceLocationId, + String sourceLocationCode, + long targetLocationId, + String targetLocationCode, + int customerCount, + int positionCount, + long packageCount, + int palletVersion, + String status, + LocalDateTime completedAt, + boolean replayed +) { + public PdaInventoryMoveResponse asReplay() { + return replayed ? this : new PdaInventoryMoveResponse(moveNo, palletId, palletCode, sourceLocationId, + sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount, + packageCount, palletVersion, status, completedAt, true); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java new file mode 100644 index 000000000..c57580eaa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.pda.move.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; + +@Data +@TableName("ll_pda_inventory_move") +public class PdaInventoryMove { + @TableId(type = IdType.INPUT) private Long id; + private String moveNo; + private Long overseasWarehouseId; + private Long palletId; + private String palletCode; + private Long sourceLocationId; + private String sourceLocationCode; + private Long targetLocationId; + private String targetLocationCode; + private Integer customerCount; + private Integer positionCount; + private Long packageCount; + private Integer palletVersionBefore; + private Integer palletVersionAfter; + private String operatorKey; + private String reason; + private String idempotencyKey; + private String requestHash; + private String status; + private String resultJson; + private LocalDateTime createdAt; + private LocalDateTime completedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java new file mode 100644 index 000000000..4347aa101 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.pda.move.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove; +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 PdaInventoryMoveMapper extends BaseMapper { + @Select("SELECT * FROM ll_pda_inventory_move WHERE overseas_warehouse_id=#{warehouseId} " + + "AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaInventoryMove find(@Param("warehouseId") long warehouseId, + @Param("operatorKey") String operatorKey, @Param("key") String key); + + @Select("SELECT * FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " + + "AND pallet_id=#{palletId} AND " + + "(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages)>0 " + + "ORDER BY customer_id,id FOR UPDATE") + List lockPositions(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java new file mode 100644 index 000000000..ab7b71d58 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java @@ -0,0 +1,191 @@ +package com.baiyue.lastleg.pda.move.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse; +import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove; +import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +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.LocalDateTime; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +@Service +public class PdaInventoryMoveService { + @Resource private PdaInventoryMoveMapper moves; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryLedgerMapper ledgers; + @Resource private WarehouseResourceGateway warehouse; + @Resource private PalletPhysicalGateway physical; + @Resource private PdaWarehouseService history; + @Resource private ObjectMapper objectMapper; + + @Transactional + public PdaInventoryMoveResponse move(long warehouseId, String operatorKey, PdaInventoryMoveRequest request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaInventoryMove existing = moves.find(warehouseId, operatorKey, key); + if (existing != null) return replay(existing, requestHash); + + var pallet = warehouse.resolvePallet(required(request.sourcePalletCode(), "Source pallet code is required")); + if (pallet.warehouseId() != warehouseId) throw conflict("Source pallet belongs to another overseas warehouse"); + var target = warehouse.resolveLocation(warehouseId, + required(request.targetLocationCode(), "Target location code is required")); + if (!Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(target.status())) { + throw conflict("Target location is not available"); + } + boolean recoveringRemoteSuccess = pallet.locationId() == target.locationId() + && pallet.version() == request.expectedPalletVersion() + 1; + if (!recoveringRemoteSuccess && pallet.version() != request.expectedPalletVersion()) { + throw conflict("Source pallet version changed; rescan it"); + } + + List inventory = moves.lockPositions(String.valueOf(warehouseId), pallet.palletId()); + if (inventory.isEmpty()) throw conflict("Source pallet has no movable inventory"); + Set inventoryLocations = new HashSet<>(); + inventory.forEach(position -> inventoryLocations.add(position.getWarehouseLocationId())); + if (inventoryLocations.contains(null) || inventoryLocations.size() != 1) { + throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving"); + } + long sourceLocationId = inventoryLocations.iterator().next(); + if (!recoveringRemoteSuccess && sourceLocationId != pallet.locationId()) { + throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving"); + } + if (sourceLocationId == target.locationId()) throw badRequest("Source and target locations must differ"); + var source = warehouse.resolveLocation(warehouseId, sourceLocationId); + long packages = inventory.stream().mapToLong(PdaInventoryMoveService::physical).sum(); + Set customers = new HashSet<>(); + inventory.forEach(position -> customers.add(position.getCustomerId())); + + long moveId = IdWorker.getId(); + PdaInventoryMove operation = new PdaInventoryMove(); + operation.setId(moveId); operation.setMoveNo("MV-" + Long.toUnsignedString(moveId)); + operation.setOverseasWarehouseId(warehouseId); operation.setPalletId(pallet.palletId()); + operation.setPalletCode(pallet.palletNo()); operation.setSourceLocationId(source.locationId()); + operation.setSourceLocationCode(source.locationCode()); operation.setTargetLocationId(target.locationId()); + operation.setTargetLocationCode(target.locationCode()); operation.setCustomerCount(customers.size()); + operation.setPositionCount(inventory.size()); operation.setPackageCount(packages); + operation.setPalletVersionBefore(pallet.version()); operation.setOperatorKey(operatorKey); + operation.setReason(request.reason().trim()); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); + operation.setStatus("PROCESSING"); operation.setCreatedAt(LocalDateTime.now()); + try { moves.insert(operation); } + catch (DuplicateKeyException duplicate) { + PdaInventoryMove winner = moves.find(warehouseId, operatorKey, key); + if (winner == null) throw duplicate; + return replay(winner, requestHash); + } + + String transitionKey = transitionKey(operatorKey, key, pallet.palletId()); + var transitionSource = new PalletPhysicalGateway.PalletReference(pallet.palletId(), pallet.palletNo(), + warehouseId, source.locationId(), "OCCUPIED", pallet.fbaWarehouseCode(), request.expectedPalletVersion()); + var relocated = physical.apply(warehouseId, transitionSource, "OCCUPIED", target.locationId(), + transitionKey, request.reason()); + if (relocated.afterLocationId() != target.locationId() + || !"APPLIED".equals(relocated.operationStatus())) { + throw new IllegalStateException("Warehouse relocation returned another target location"); + } + LocalDateTime completedAt = LocalDateTime.now(); + for (InventoryPosition position : inventory) { + InventoryBalance balance = balances.selectById(position.getBalanceId()); + if (balance == null || !position.getCustomerId().equals(balance.getCustomerId()) + || !String.valueOf(warehouseId).equals(balance.getOverseasWarehouseId())) { + throw new IllegalStateException("Inventory balance scope is inconsistent during relocation"); + } + position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode()); + position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt); + positions.updateById(position); + ledger(operation, position, balance, operatorKey, key, request.reason(), completedAt); + } + operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED"); + operation.setCompletedAt(completedAt); + PdaInventoryMoveResponse response = response(operation, false); + operation.setResultJson(write(response)); moves.updateById(operation); + history.record(warehouseId, null, operatorKey, "INVENTORY_MOVE", operation.getMoveNo(), "COMPLETED", + "INVENTORY_MOVE:" + key, requestHash, + pallet.palletNo() + ":" + source.locationCode() + " → " + target.locationCode() + + "," + packages + " 箱"); + return response; + } + + private PdaInventoryMoveResponse replay(PdaInventoryMove operation, String requestHash) { + if (!requestHash.equals(operation.getRequestHash())) { + throw conflict("Idempotency key was reused with a different inventory move"); + } + if (!"COMPLETED".equals(operation.getStatus()) || operation.getResultJson() == null) { + throw conflict("Inventory move is still processing"); + } + try { return objectMapper.readValue(operation.getResultJson(), PdaInventoryMoveResponse.class).asReplay(); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read inventory move replay", error); } + } + + private PdaInventoryMoveResponse response(PdaInventoryMove value, boolean replayed) { + return new PdaInventoryMoveResponse(value.getMoveNo(), value.getPalletId(), value.getPalletCode(), + value.getSourceLocationId(), value.getSourceLocationCode(), value.getTargetLocationId(), + value.getTargetLocationCode(), value.getCustomerCount(), value.getPositionCount(), + value.getPackageCount(), value.getPalletVersionAfter(), value.getStatus(), value.getCompletedAt(), replayed); + } + + private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance, + String operator, String key, String reason, LocalDateTime now) { + InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId()); + row.setBalanceId(balance.getId()); row.setPositionId(position.getId()); + row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId())); + row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId()); + row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo()); + row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode()); + row.setBusinessType("INTERNAL_MOVE"); + row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo()); + row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo()); + row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId()); + row.setStatusFrom("OCCUPIED"); row.setStatusTo("OCCUPIED"); long packages = physical(position); + row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages); + row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now); + ledgers.insert(row); + } + + 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 String transitionKey(String operator, String key, long palletId) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(("INVENTORY_MOVE|" + operator + "|" + key + "|" + palletId) + .getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize inventory move", error); } + } + private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + + value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); } + private static long value(Long value) { return value == null ? 0 : value; } + private static String required(String value, String message) { if (value == null || value.isBlank()) throw badRequest(message); return value.trim(); } + private static ResponseStatusException badRequest(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/pda/security/PdaWarehouseIdentity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java new file mode 100644 index 000000000..cbbf5139b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.pda.security; + +import com.baiyue.common.security.AuthenticatedUser; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public record PdaWarehouseIdentity(String operatorKey, String operatorName, long overseasWarehouseId) { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + public static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER"; + + public static PdaWarehouseIdentity require(AuthenticatedUser user) { + if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0 + || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "Authenticated platform warehouse PDA identity is required"); + } + if (!user.permissions().contains(PICKER_ROLE)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "WAREHOUSE_PICKER role is required"); + } + if (!user.permissions().contains(OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); + } + if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "PDA operator must be bound to one overseas warehouse"); + } + String operator = String.valueOf(user.userId()); + return new PdaWarehouseIdentity(operator, + user.username() == null || user.username().isBlank() ? operator : user.username(), + user.overseasWarehouseId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java new file mode 100644 index 000000000..fb103115a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.pda.stocktake.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.service.PdaStocktakeService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/stocktakes") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaStocktakeController { + @Resource private PdaStocktakeService service; + + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, @Valid PdaStocktakeRequests.TaskQuery query) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.page(identity.overseasWarehouseId(), query)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaStocktakeRequests.Create request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.create(identity.overseasWarehouseId(), identity.operatorKey(), request)); + } + + @GetMapping("/{taskNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.detail(identity.overseasWarehouseId(), taskNo)); + } + + @PostMapping("/{taskNo}/counts") + public ApiResponse count(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Count request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.count(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request)); + } + + @PostMapping("/{taskNo}/submit") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Submit request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.submit(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java new file mode 100644 index 000000000..bfba023cd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.pda.stocktake.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class PdaStocktakeRequests { + private PdaStocktakeRequests() { } + + public record TaskQuery(@Min(1) long page, @Min(1) @Max(100) long size, String status) { + public TaskQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record Create(@NotBlank @Size(max = 16) String scopeType, + @NotBlank @Size(max = 128) String scopeCode, + @NotBlank @Size(max = 128) String idempotencyKey) { } + + public record Count(@NotNull Long positionId, @PositiveOrZero long actualPackages, + @PositiveOrZero long expectedVersion, + @NotBlank @Size(max = 128) String idempotencyKey) { } + + public record Submit(@PositiveOrZero long expectedVersion, + @NotBlank @Size(max = 500) String reason, + @NotBlank @Size(max = 128) String idempotencyKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java new file mode 100644 index 000000000..ce72041aa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.pda.stocktake.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaStocktakeResponses { + private PdaStocktakeResponses() { } + + public record Summary(String taskNo, String scopeType, String scopeCode, String status, + int positionCount, int countedCount, int differenceCount, long version, + LocalDateTime createdAt, LocalDateTime submittedAt) { } + + public record Item(long itemId, String customerId, long balanceId, long positionId, + String skuCode, String productName, String inboundCode, + long palletId, String palletCode, long locationId, String locationCode, + long bookPackages, Long actualPackages, Long differencePackages, + String status, long positionVersion, long version) { } + + public record Detail(Summary task, List items) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java new file mode 100644 index 000000000..a0246bf33 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.pda.stocktake.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; + +@Data +@TableName("ll_pda_stocktake_action") +public class PdaStocktakeAction { + @TableId(type = IdType.INPUT) private Long id; + private Long overseasWarehouseId; + private String operatorKey; + private String idempotencyKey; + private String actionType; + private Long taskId; + private String requestHash; + private String status; + private String resultJson; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java new file mode 100644 index 000000000..0473f5343 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.pda.stocktake.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; + +@Data +@TableName("ll_pda_stocktake_item") +public class PdaStocktakeItem { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private String customerId; + private Long balanceId; + private Long positionId; + private String skuCode; + private String productName; + private String inboundCode; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Long bookPackages; + private Long actualPackages; + private Long differencePackages; + private String status; + private Long positionVersion; + private String countedBy; + private Long version; + private LocalDateTime countedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java new file mode 100644 index 000000000..733b8ac43 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.pda.stocktake.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; + +@Data +@TableName("ll_pda_stocktake_task") +public class PdaStocktakeTask { + @TableId(type = IdType.INPUT) private Long id; + private String taskNo; + private Long overseasWarehouseId; + private String scopeType; + private Long scopeResourceId; + private String scopeCode; + private String status; + private Integer positionCount; + private Integer countedCount; + private Integer differenceCount; + private String createdBy; + private String submittedBy; + private String submitReason; + private String idempotencyKey; + private String requestHash; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime submittedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java new file mode 100644 index 000000000..3e42d42d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaStocktakeActionMapper extends BaseMapper { + @Select("SELECT * FROM ll_pda_stocktake_action WHERE overseas_warehouse_id=#{warehouseId} " + + "AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaStocktakeAction find(@Param("warehouseId") long warehouseId, + @Param("operatorKey") String operatorKey, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java new file mode 100644 index 000000000..e2ab71e35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaStocktakeInventoryMapper { + @Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") + InventoryPosition lockPosition(@Param("positionId") long positionId, @Param("customerId") String customerId, + @Param("warehouseId") String warehouseId); + + @Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") + InventoryBalance lockBalance(@Param("balanceId") long balanceId, @Param("customerId") String customerId, + @Param("warehouseId") String warehouseId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java new file mode 100644 index 000000000..4347c6a68 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem; +import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface PdaStocktakeItemMapper extends BaseMapper { + String CANDIDATE = "SELECT p.customer_id,b.id balance_id,p.id position_id,b.sku_code," + + "COALESCE(NULLIF(b.product_name_zh,''),b.product_name_en) product_name,b.inbound_code," + + "p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," + + "p.warehouse_location_code location_code," + + "(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages) book_packages," + + "p.version position_version FROM ll_inventory_position p JOIN ll_inventory_balance b " + + "ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.overseas_warehouse_id=#{warehouseId} "; + + @Select(CANDIDATE + "AND p.pallet_id=#{palletId} AND " + + "(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY p.customer_id,p.id") + List candidatesByPallet(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId); + + @Select(CANDIDATE + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY p.customer_id,p.pallet_no,p.id") + List candidatesByLocation(@Param("warehouseId") String warehouseId, + @Param("locationId") long locationId, + @Param("locationCode") String locationCode); + + @Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY location_code,pallet_code,position_id") + List list(@Param("taskId") long taskId); + + @Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY id FOR UPDATE") + List lockAll(@Param("taskId") long taskId); + + @Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND position_id=#{positionId} LIMIT 1 FOR UPDATE") + PdaStocktakeItem lockItem(@Param("taskId") long taskId, @Param("positionId") long positionId); + + @Update("UPDATE ll_pda_stocktake_item SET actual_packages=#{actualPackages}," + + "difference_packages=#{differencePackages},status=#{status},counted_by=#{operatorKey}," + + "counted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 " + + "WHERE id=#{itemId} AND version=#{expectedVersion} AND status IN ('PENDING','COUNTED')") + int countItem(@Param("itemId") long itemId, @Param("expectedVersion") long expectedVersion, + @Param("actualPackages") long actualPackages, @Param("differencePackages") long differencePackages, + @Param("status") String status, @Param("operatorKey") String operatorKey); + + @Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND actual_packages IS NOT NULL") + int counted(@Param("taskId") long taskId); + + @Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " + + "AND actual_packages IS NOT NULL AND difference_packages<>0") + int differences(@Param("taskId") long taskId); + + @Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," + + "pallet_id,pallet_code,location_id,location_code,book_packages,actual_packages,difference_packages," + + "status,position_version,version FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " + + "ORDER BY location_code,pallet_code,position_id") + List views(@Param("taskId") long taskId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java new file mode 100644 index 000000000..77ffae857 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface PdaStocktakeTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " + + "AND created_by=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaStocktakeTask findCreate(@Param("warehouseId") long warehouseId, + @Param("operatorKey") String operatorKey, @Param("key") String key); + + @Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " + + "AND task_no=#{taskNo} LIMIT 1") + PdaStocktakeTask find(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo); + + @Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " + + "AND task_no=#{taskNo} LIMIT 1 FOR UPDATE") + PdaStocktakeTask lock(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo); + + @Select("") + List page(@Param("warehouseId") long warehouseId, + @Param("status") String status, + @Param("offset") long offset, @Param("limit") long limit); + + @Select("") + long count(@Param("warehouseId") long warehouseId, @Param("status") String status); + + @Update("UPDATE ll_pda_stocktake_task SET counted_count=#{countedCount},difference_count=#{differenceCount}," + + "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND version=#{expectedVersion} " + + "AND status='IN_PROGRESS'") + int refreshCounts(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion, + @Param("countedCount") int countedCount, @Param("differenceCount") int differenceCount); + + @Update("UPDATE ll_pda_stocktake_task SET status='SUBMITTED',submitted_by=#{operatorKey}," + + "submit_reason=#{reason},submitted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3)," + + "version=version+1 WHERE id=#{taskId} AND version=#{expectedVersion} AND status='IN_PROGRESS'") + int submit(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion, + @Param("operatorKey") String operatorKey, @Param("reason") String reason); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java new file mode 100644 index 000000000..41443f089 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.pda.stocktake.model; + +import lombok.Data; + +@Data +public class StocktakeCandidate { + private String customerId; + private Long balanceId; + private Long positionId; + private String skuCode; + private String productName; + private String inboundCode; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Long bookPackages; + private Long positionVersion; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java new file mode 100644 index 000000000..101e03b74 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java @@ -0,0 +1,323 @@ +package com.baiyue.lastleg.pda.stocktake.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper; +import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +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.LocalDateTime; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +@Service +public class PdaStocktakeService { + @Resource private PdaStocktakeTaskMapper tasks; + @Resource private PdaStocktakeItemMapper items; + @Resource private PdaStocktakeActionMapper actions; + @Resource private PdaStocktakeInventoryMapper inventory; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryLedgerMapper ledgers; + @Resource private WarehouseResourceGateway warehouse; + @Resource private PdaWarehouseService history; + @Resource private ObjectMapper objectMapper; + + public PageResult page(long warehouseId, PdaStocktakeRequests.TaskQuery query) { + String status = status(query.status()); + long offset = (query.page() - 1) * query.size(); + List rows = tasks.page(warehouseId, status, offset, query.size()); + long total = tasks.count(warehouseId, status); + return new PageResult<>(rows, query.page(), query.size(), total, + total == 0 ? 0 : (total + query.size() - 1) / query.size()); + } + + public PdaStocktakeResponses.Detail detail(long warehouseId, String taskNo) { + PdaStocktakeTask task = requireTask(warehouseId, taskNo, false); + return detail(task); + } + + @Transactional + public PdaStocktakeResponses.Detail create(long warehouseId, String operatorKey, + PdaStocktakeRequests.Create request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeTask replay = tasks.findCreate(warehouseId, operatorKey, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with another stocktake"); + return detail(replay); + } + + String scopeType = required(request.scopeType(), "Scope type is required").toUpperCase(); + String scopeCode = required(request.scopeCode(), "Scope code is required"); + long resourceId; + List candidates; + if ("PALLET".equals(scopeType)) { + var pallet = warehouse.resolvePallet(scopeCode); + if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse"); + resourceId = pallet.palletId(); + scopeCode = pallet.palletNo(); + candidates = items.candidatesByPallet(String.valueOf(warehouseId), resourceId); + } else if ("LOCATION".equals(scopeType)) { + var location = warehouse.resolveLocation(warehouseId, scopeCode); + if (!List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status())) { + throw conflict("Stocktake location is not active"); + } + resourceId = location.locationId(); + scopeCode = location.locationCode(); + candidates = items.candidatesByLocation(String.valueOf(warehouseId), resourceId, scopeCode); + } else { + throw badRequest("scopeType must be LOCATION or PALLET"); + } + if (candidates.isEmpty()) throw conflict("No inventory is available in the scanned scope"); + + long taskId = IdWorker.getId(); + LocalDateTime now = LocalDateTime.now(); + PdaStocktakeTask task = new PdaStocktakeTask(); + task.setId(taskId); task.setTaskNo("CC-" + Long.toUnsignedString(taskId)); + task.setOverseasWarehouseId(warehouseId); task.setScopeType(scopeType); task.setScopeResourceId(resourceId); + task.setScopeCode(scopeCode); task.setStatus("IN_PROGRESS"); task.setPositionCount(candidates.size()); + task.setCountedCount(0); task.setDifferenceCount(0); task.setCreatedBy(operatorKey); + task.setIdempotencyKey(key); task.setRequestHash(requestHash); task.setVersion(0L); + task.setCreatedAt(now); task.setUpdatedAt(now); + try { + tasks.insert(task); + } catch (DuplicateKeyException duplicate) { + PdaStocktakeTask winner = tasks.findCreate(warehouseId, operatorKey, key); + if (winner == null || !requestHash.equals(winner.getRequestHash())) throw duplicate; + return detail(winner); + } + for (StocktakeCandidate candidate : candidates) { + PdaStocktakeItem item = new PdaStocktakeItem(); + item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setCustomerId(candidate.getCustomerId()); + item.setBalanceId(candidate.getBalanceId()); item.setPositionId(candidate.getPositionId()); + item.setSkuCode(candidate.getSkuCode()); item.setProductName(candidate.getProductName()); + item.setInboundCode(candidate.getInboundCode()); item.setPalletId(candidate.getPalletId()); + item.setPalletCode(candidate.getPalletCode()); item.setLocationId(candidate.getLocationId()); + item.setLocationCode(candidate.getLocationCode()); item.setBookPackages(value(candidate.getBookPackages())); + item.setStatus("PENDING"); item.setPositionVersion(value(candidate.getPositionVersion())); + item.setVersion(0L); item.setCreatedAt(now); item.setUpdatedAt(now); items.insert(item); + } + history.record(warehouseId, null, operatorKey, "STOCKTAKE_CREATE", task.getTaskNo(), "IN_PROGRESS", + "STOCKTAKE_CREATE:" + key, requestHash, + scopeType + " " + scopeCode + ",共 " + candidates.size() + " 个库存位置"); + return detail(task); + } + + @Transactional + public PdaStocktakeResponses.Item count(long warehouseId, String operatorKey, String taskNo, + PdaStocktakeRequests.Count request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "COUNT", requestHash, taskNo); + if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Item.class); + + PdaStocktakeTask task = requireTask(warehouseId, taskNo, true); + if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Submitted stocktake cannot be changed"); + PdaStocktakeItem item = items.lockItem(task.getId(), request.positionId()); + if (item == null) throw notFound("Stocktake position was not found in this task"); + long difference = request.actualPackages() - value(item.getBookPackages()); + if (items.countItem(item.getId(), request.expectedVersion(), request.actualPackages(), difference, + difference == 0 ? "MATCHED" : "DIFFERENCE", operatorKey) != 1) { + throw conflict("Stocktake item version changed; refresh and retry"); + } + int counted = items.counted(task.getId()); + int differences = items.differences(task.getId()); + if (tasks.refreshCounts(task.getId(), value(task.getVersion()), counted, differences) != 1) { + throw conflict("Stocktake task version changed; refresh and retry"); + } + PdaStocktakeResponses.Item response = items.views(task.getId()).stream() + .filter(row -> row.positionId() == request.positionId()).findFirst().orElseThrow(); + complete(action, response); + return response; + } + + @Transactional + public PdaStocktakeResponses.Detail submit(long warehouseId, String operatorKey, String taskNo, + PdaStocktakeRequests.Submit request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "SUBMIT", requestHash, taskNo); + if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Detail.class); + + PdaStocktakeTask task = requireTask(warehouseId, taskNo, true); + if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Stocktake was already submitted"); + if (value(task.getVersion()) != request.expectedVersion()) throw conflict("Stocktake task version changed; refresh and retry"); + List taskItems = items.lockAll(task.getId()); + if (taskItems.isEmpty() || taskItems.stream().anyMatch(item -> item.getActualPackages() == null)) { + throw conflict("Every stocktake position must be counted before submission"); + } + + Map changedBalances = new HashMap<>(); + LocalDateTime now = LocalDateTime.now(); + for (PdaStocktakeItem item : taskItems) { + InventoryPosition position = inventory.lockPosition(item.getPositionId(), item.getCustomerId(), + String.valueOf(warehouseId)); + if (position == null || value(position.getVersion()) != value(item.getPositionVersion())) { + throw conflict("Inventory changed after counting; recreate the stocktake task"); + } + long before = physical(position); + if (before != value(item.getBookPackages())) { + throw conflict("Inventory package total changed after counting; recreate the stocktake task"); + } + long fixed = value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + + value(position.getPickedPackages()) + value(position.getReceivingPackages()); + if (value(item.getActualPackages()) < fixed) { + throw conflict("Actual packages cannot be lower than reserved or processing packages"); + } + long newAvailable = value(item.getActualPackages()) - fixed; + long delta = newAvailable - value(position.getAvailablePackages()); + InventoryBalance balance = changedBalances.get(item.getBalanceId()); + if (balance == null) { + balance = inventory.lockBalance(item.getBalanceId(), item.getCustomerId(), String.valueOf(warehouseId)); + if (balance == null) throw new IllegalStateException("Stocktake inventory balance is missing"); + changedBalances.put(item.getBalanceId(), balance); + } + long balanceAfter = value(balance.getAvailablePackages()) + delta; + if (balanceAfter < 0) throw conflict("Stocktake adjustment would make inventory negative"); + + position.setAvailablePackages(newAvailable); position.setStatus(physicalAfter(position, newAvailable) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(now); positions.updateById(position); + balance.setAvailablePackages(balanceAfter); balance.setVersion(value(balance.getVersion()) + 1); balance.setUpdatedAt(now); + + ledger(task, item, position, balance, operatorKey, key, request.reason(), before, delta, + value(item.getActualPackages()), now); + item.setStatus(delta == 0 ? "MATCHED" : "ADJUSTED"); item.setUpdatedAt(now); + item.setVersion(value(item.getVersion()) + 1); items.updateById(item); + } + changedBalances.values().forEach(balances::updateById); + if (tasks.submit(task.getId(), request.expectedVersion(), operatorKey, request.reason()) != 1) { + throw conflict("Stocktake task version changed; refresh and retry"); + } + history.record(warehouseId, null, operatorKey, "STOCKTAKE_SUBMIT", taskNo, "COMPLETED", + "STOCKTAKE_SUBMIT:" + key, requestHash, + "盘点提交,差异位置 " + task.getDifferenceCount() + " 个;原因:" + request.reason().trim()); + PdaStocktakeResponses.Detail response = detail(requireTask(warehouseId, taskNo, false)); + complete(action, response); + return response; + } + + private PdaStocktakeResponses.Detail detail(PdaStocktakeTask task) { + return new PdaStocktakeResponses.Detail(summary(task), items.views(task.getId())); + } + + private PdaStocktakeResponses.Summary summary(PdaStocktakeTask task) { + return new PdaStocktakeResponses.Summary(task.getTaskNo(), task.getScopeType(), task.getScopeCode(), + task.getStatus(), intValue(task.getPositionCount()), intValue(task.getCountedCount()), + intValue(task.getDifferenceCount()), value(task.getVersion()), task.getCreatedAt(), task.getSubmittedAt()); + } + + private PdaStocktakeTask requireTask(long warehouseId, String taskNo, boolean lock) { + String normalized = required(taskNo, "Stocktake task number is required"); + PdaStocktakeTask task = lock ? tasks.lock(warehouseId, normalized) : tasks.find(warehouseId, normalized); + if (task == null) throw notFound("Stocktake task was not found in the bound warehouse"); + return task; + } + + private PdaStocktakeAction beginAction(long warehouseId, String operatorKey, String key, String type, + String requestHash, String taskNo) { + PdaStocktakeAction existing = actions.find(warehouseId, operatorKey, key); + if (existing != null) return replay(existing, type, requestHash); + PdaStocktakeAction action = new PdaStocktakeAction(); + action.setId(IdWorker.getId()); action.setOverseasWarehouseId(warehouseId); action.setOperatorKey(operatorKey); + action.setIdempotencyKey(key); action.setActionType(type); action.setRequestHash(requestHash); + PdaStocktakeTask task = tasks.find(warehouseId, taskNo); action.setTaskId(task == null ? null : task.getId()); + action.setStatus("PROCESSING"); action.setCreatedAt(LocalDateTime.now()); action.setUpdatedAt(action.getCreatedAt()); + try { actions.insert(action); return action; } + catch (DuplicateKeyException duplicate) { + PdaStocktakeAction winner = actions.find(warehouseId, operatorKey, key); + if (winner == null) throw duplicate; + return replay(winner, type, requestHash); + } + } + + private PdaStocktakeAction replay(PdaStocktakeAction action, String type, String requestHash) { + if (!type.equals(action.getActionType()) || !requestHash.equals(action.getRequestHash())) { + throw conflict("Idempotency key was reused with a different stocktake action"); + } + if (!"COMPLETED".equals(action.getStatus())) throw conflict("Stocktake action is still processing"); + return action; + } + + private void complete(PdaStocktakeAction action, Object response) { + action.setStatus("COMPLETED"); action.setResultJson(write(response)); action.setUpdatedAt(LocalDateTime.now()); + actions.updateById(action); + } + + private T read(PdaStocktakeAction action, Class type) { + try { return objectMapper.readValue(action.getResultJson(), type); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read stocktake replay", error); } + } + + private void ledger(PdaStocktakeTask task, PdaStocktakeItem item, InventoryPosition position, + InventoryBalance balance, String operator, String key, String reason, + long before, long delta, long after, LocalDateTime now) { + InventoryLedger row = new InventoryLedger(); + row.setId(IdWorker.getId()); row.setCustomerId(item.getCustomerId()); row.setBalanceId(balance.getId()); + row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(task.getOverseasWarehouseId())); + row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId()); + row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo()); + row.setSkuCode(item.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode()); + row.setBusinessType("STOCKTAKE_ADJUST"); row.setBusinessOrderId(task.getId()); row.setBusinessOrderNo(task.getTaskNo()); + row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo()); + row.setIdempotencyKey("STOCKTAKE:" + task.getId() + ":" + item.getId()); + row.setStatusFrom("PHYSICAL"); row.setStatusTo("PHYSICAL"); row.setBeforePackages(before); + row.setDeltaPackages(delta); row.setAfterPackages(after); row.setOperatorId(operator); row.setRemark(reason.trim()); + row.setOccurredAt(now); row.setCreatedAt(now); ledgers.insert(row); + } + + 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 String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize stocktake operation", error); } + } + private static String status(String value) { + String normalized = trim(value); if (normalized == null) return null; + normalized = normalized.toUpperCase(); + if (!List.of("IN_PROGRESS", "SUBMITTED").contains(normalized)) throw badRequest("Unsupported stocktake status"); + return normalized; + } + private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + + value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); } + private static long physicalAfter(InventoryPosition value, long available) { return available + value(value.getHoldPackages()) + + value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); } + private static long value(Long value) { return value == null ? 0 : value; } + private static int intValue(Integer value) { return value == null ? 0 : value; } + private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); return normalized; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java new file mode 100644 index 000000000..874bb0e17 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.pda.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/pda/v1/last-leg") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaWarehouseController { + @Resource private PdaWarehouseService service; + + @PostMapping("/inventory/resolve") + public ApiResponse inventory(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaWarehouseRequests.Scan request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.inventory(identity.overseasWarehouseId(), request)); + } + + @PostMapping("/locations/resolve") + public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaWarehouseRequests.Scan request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.location(identity.overseasWarehouseId(), request)); + } + + @PostMapping("/pallets/resolve") + public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaWarehouseRequests.Scan request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pallet(identity.overseasWarehouseId(), request)); + } + + @GetMapping("/operations") + public ApiResponse> operations( + @AuthenticationPrincipal AuthenticatedUser user, @Valid PdaWarehouseRequests.HistoryQuery query) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.history(identity.overseasWarehouseId(), identity.operatorKey(), query)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java new file mode 100644 index 000000000..aaeef8112 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.pda.warehouse.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public final class PdaWarehouseRequests { + private PdaWarehouseRequests() { } + + public record Scan(@NotBlank @Size(max = 128) String code) { } + public record HistoryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String operationType) { + public HistoryQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java new file mode 100644 index 000000000..cb5b3921e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.pda.warehouse.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaWarehouseResponses { + private PdaWarehouseResponses() { } + + public record InventoryScan(String queryCode, List items) { } + public record InventoryItem(String customerId, String customerName, long balanceId, long positionId, + String skuCode, String productName, String inboundCode, String cartonMark, + long palletId, String palletCode, Long locationId, String locationCode, + long availablePackages, long holdPackages, long receivingPackages, + String status, long version) { } + public record Location(long locationId, String locationCode, String locationType, BigDecimal maxLoadKg, + String status, long occupiedPackages, int inventoryPositionCount) { } + public record Pallet(long palletId, String palletCode, String status, int version, + long locationId, String locationCode, long occupiedPackages, + int inventoryPositionCount, List inventory) { } + public record Operation(long operationId, String operationType, String businessKey, String status, + String summary, String customerId, LocalDateTime occurredAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java new file mode 100644 index 000000000..59e428c49 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.pda.warehouse.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; + +@Data +@TableName("ll_pda_operation_history") +public class PdaOperationHistory { + @TableId(type = IdType.INPUT) + private Long id; + private Long overseasWarehouseId; + private String customerId; + private String operatorKey; + private String operationType; + private String businessKey; + private String status; + private String idempotencyKey; + private String requestHash; + private String summary; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java new file mode 100644 index 000000000..586d7dde6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.pda.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; +import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection; +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 PdaWarehouseMapper extends BaseMapper { + String INVENTORY_SELECT = "SELECT DISTINCT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," + + "COALESCE(NULLIF(r.sku_code,''),b.sku_code) sku_code," + + "COALESCE(NULLIF(r.product_name_zh,''),b.product_name_zh) product_name_zh," + + "COALESCE(NULLIF(r.product_name_en,''),b.product_name_en) product_name_en,b.inbound_code," + + "c.carton_mark,p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," + + "p.warehouse_location_code location_code,p.available_packages,p.hold_packages,p.receiving_packages," + + "p.status,p.version FROM ll_inventory_balance b JOIN ll_inventory_position p " + + "ON p.balance_id=b.id AND p.customer_id=b.customer_id " + + "LEFT JOIN ll_inbound_stock_receipt r ON r.inventory_balance_id=b.id AND r.inventory_position_id=p.id " + + "LEFT JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id " + + "AND c.warehouse_pallet_id=p.pallet_id AND c.carton_mark=#{code} AND c.status='RECEIVED' "; + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " + + "AND (UPPER(b.sku_code)=UPPER(#{code}) OR UPPER(r.sku_code)=UPPER(#{code}) " + + "OR b.inbound_code=#{code} OR c.carton_mark=#{code}) " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id") + List scanInventory(@Param("warehouseId") String warehouseId, + @Param("code") String code); + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.id") + List inventoryByPallet(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId, + @Param("code") String ignored); + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " + + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id") + List inventoryByLocation(@Param("warehouseId") String warehouseId, + @Param("locationId") long locationId, + @Param("locationCode") String locationCode, + @Param("code") String ignored); + + @Select("") + List history( + @Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey, + @Param("operationType") String operationType, @Param("offset") long offset, @Param("limit") long limit); + + @Select("") + long countHistory(@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey, + @Param("operationType") String operationType); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java new file mode 100644 index 000000000..a34b41d97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.pda.warehouse.model; + +import lombok.Data; + +@Data +public class PdaInventoryProjection { + private String customerId; + private String customerName; + private Long balanceId; + private Long positionId; + private String skuCode; + private String productNameZh; + private String productNameEn; + private String inboundCode; + private String cartonMark; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Long availablePackages; + private Long holdPackages; + private Long receivingPackages; + private String status; + private Long version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java new file mode 100644 index 000000000..b8500a76e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.pda.warehouse.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests; +import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses; +import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; +import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper; +import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; + +@Service +public class PdaWarehouseService { + @Resource private PdaWarehouseMapper mapper; + @Resource private WarehouseResourceGateway warehouse; + + public PdaWarehouseResponses.InventoryScan inventory(long warehouseId, PdaWarehouseRequests.Scan request) { + String code = code(request.code()); + List items = items(mapper.scanInventory(String.valueOf(warehouseId), code)); + if (items.isEmpty()) throw notFound("Inventory code was not found in the bound warehouse"); + return new PdaWarehouseResponses.InventoryScan(code, items); + } + + public PdaWarehouseResponses.Location location(long warehouseId, PdaWarehouseRequests.Scan request) { + WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId, + code(request.code())); + List inventory = items(mapper.inventoryByLocation( + String.valueOf(warehouseId), location.locationId(), location.locationCode(), "__NO_CARTON__")); + long packages = inventory.stream().mapToLong(this::packages).sum(); + int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count(); + return new PdaWarehouseResponses.Location(location.locationId(), location.locationCode(), + location.locationType(), location.maxLoadKg(), location.status(), packages, positions); + } + + public PdaWarehouseResponses.Pallet pallet(long warehouseId, PdaWarehouseRequests.Scan request) { + WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(code(request.code())); + if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse"); + WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId, + pallet.locationId()); + List inventory = items(mapper.inventoryByPallet( + String.valueOf(warehouseId), pallet.palletId(), "__NO_CARTON__")); + long packages = inventory.stream().mapToLong(this::packages).sum(); + int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count(); + return new PdaWarehouseResponses.Pallet(pallet.palletId(), pallet.palletNo(), pallet.status(), pallet.version(), + location.locationId(), location.locationCode(), packages, positions, inventory); + } + + public PageResult history(long warehouseId, String operatorKey, + PdaWarehouseRequests.HistoryQuery query) { + long offset = (query.page() - 1) * query.size(); + List rows = mapper.history(warehouseId, operatorKey, + trim(query.operationType()), offset, query.size()); + long total = mapper.countHistory(warehouseId, operatorKey, trim(query.operationType())); + return new PageResult<>(rows, query.page(), query.size(), total, + total == 0 ? 0 : (total + query.size() - 1) / query.size()); + } + + public void record(long warehouseId, String customerId, String operatorKey, String operationType, + String businessKey, String status, String idempotencyKey, String requestHash, String summary) { + PdaOperationHistory history = new PdaOperationHistory(); + history.setId(IdWorker.getId()); history.setOverseasWarehouseId(warehouseId); + history.setCustomerId(trim(customerId)); history.setOperatorKey(operatorKey); + history.setOperationType(operationType); history.setBusinessKey(businessKey); history.setStatus(status); + history.setIdempotencyKey(trim(idempotencyKey)); history.setRequestHash(trim(requestHash)); + history.setSummary(trim(summary)); history.setOccurredAt(LocalDateTime.now()); history.setCreatedAt(history.getOccurredAt()); + mapper.insert(history); + } + + private List items(List values) { + LinkedHashMap result = new LinkedHashMap<>(); + for (PdaInventoryProjection value : values) { + String key = value.getPositionId() + ":" + text(value.getSkuCode()) + ":" + text(value.getCartonMark()); + result.putIfAbsent(key, new PdaWarehouseResponses.InventoryItem(value.getCustomerId(), value.getCustomerName(), + number(value.getBalanceId()), number(value.getPositionId()), value.getSkuCode(), + first(value.getProductNameZh(), value.getProductNameEn()), value.getInboundCode(), value.getCartonMark(), + number(value.getPalletId()), value.getPalletCode(), value.getLocationId(), value.getLocationCode(), + number(value.getAvailablePackages()), number(value.getHoldPackages()), number(value.getReceivingPackages()), + value.getStatus(), number(value.getVersion()))); + } + return List.copyOf(result.values()); + } + + private long packages(PdaWarehouseResponses.InventoryItem value) { + return value.availablePackages() + value.holdPackages() + value.receivingPackages(); + } + private static String code(String value) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Scan code is required"); return result; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String text(String value) { return value == null ? "" : value; } + private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; } + private static long number(Long value) { return value == null ? 0 : value; } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/ProcurementMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/ProcurementMapperConfiguration.java new file mode 100644 index 000000000..ed349662b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/ProcurementMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.procurement; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.procurement.mapper") +public class ProcurementMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java new file mode 100644 index 000000000..d8bb670f0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; +import com.baiyue.lastleg.procurement.service.OverseasProcurementService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements") +public class MerchantOverseasProcurementController { + @Resource private OverseasProcurementService service; + + @PostMapping + public ApiResponse create( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody OverseasProcurementRequests.Create request) { + return ApiResponse.success(service.create(customer(user), operator(user), request)); + } + + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.Query query) { + return ApiResponse.success(service.page(customer(user), query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) { + return ApiResponse.success(service.detail(customer(user), orderNo)); + } + + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java new file mode 100644 index 000000000..1693086fc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; +import com.baiyue.lastleg.procurement.service.OverseasProcurementService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") +public class PlatformOverseasProcurementController { + @Resource private OverseasProcurementService service; + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')") + @Operation(summary = "Page overseas procurement orders", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST) + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid OverseasProcurementRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.page(trim(customerId), query)); + } + + @GetMapping("/customer-options") + @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')") + @Operation(summary = "List procurement customer options", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST) + public ApiResponse> customerOptions( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "50") int limit) { + requirePlatform(user); + return ApiResponse.success(service.customerOptions(keyword, limit)); + } + + @GetMapping("/warehouse-options") + @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')") + @Operation(summary = "List procurement warehouse options", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST) + public ApiResponse> warehouseOptions( + @AuthenticationPrincipal AuthenticatedUser user) { + requirePlatform(user); + return ApiResponse.success(service.warehouseOptions()); + } + + @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')") + @Operation(summary = "Get an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST) + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo) { + requirePlatform(user); + return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo)); + } + + @PostMapping("/{orderNo}/review") + @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_REVIEW + "')") + @Operation(summary = "Review an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_REVIEW) + public ApiResponse review( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) { + requirePermission(user, OverseasProcurementService.REVIEW_PERMISSION); + return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PatchMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')") + @Operation(summary = "Update an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_EDIT) + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) { + requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); + return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + } + + private void requirePermission(AuthenticatedUser user, String permission) { + requirePlatform(user); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); + } + } + + private String requiredCustomer(String customerId) { + String value = trim(customerId); + if (value == null || "0".equals(value)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required"); + } + return value; + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java new file mode 100644 index 000000000..d31cd0672 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.procurement.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public final class OverseasProcurementRequests { + private OverseasProcurementRequests() {} + + public record Create( + @Size(max = 255) String customerName, + @NotBlank @Size(max = 128) String purchaseCategory, + @NotBlank @Size(max = 255) String productName, + @Size(max = 500) String productSpecification, + @Size(max = 2000) String productUrl, + @NotNull @Positive Long purchaseQuantity, + @DecimalMin(value = "0.00") BigDecimal budgetUnitPrice, + @Size(min = 3, max = 3) String currency, + @Size(max = 1000) String receivingAddress, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 64) String contactPhone, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey) {} + + public record Query( + @Min(1) long page, @Min(1) @Max(200) long size, + String orderNo, String customerKeyword, String purchaseCategory, + String productName, String status, String purchaseWarehouseId, + LocalDateTime createdFrom, LocalDateTime createdTo) {} + + public record Review( + @NotNull @Min(0) Long version, + @NotBlank String decision, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Update( + @NotNull @Min(0) Long version, + @Size(max = 128) String purchaseCategory, + @Size(max = 255) String productName, + JsonNode productSpecification, + JsonNode productUrl, + @Min(0) Long inboundQuantity, + JsonNode purchaseWarehouseId, + JsonNode purchaseWarehouseName, + JsonNode domesticCarrier, + JsonNode domesticTrackingNo, + JsonNode overseasCarrier, + JsonNode overseasTrackingNo, + JsonNode estimatedArrivalAt, + JsonNode remark) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java new file mode 100644 index 000000000..d0f94de6b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.procurement.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class OverseasProcurementResponses { + private OverseasProcurementResponses() {} + + public record Summary( + Long id, String orderNo, String customerId, String customerName, + String purchaseCategory, String productName, String productSpecification, + long purchaseQuantity, BigDecimal budgetUnitPrice, String currency, + String purchaseWarehouseId, String purchaseWarehouseName, + long inboundQuantity, long inventoryQuantity, + String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} + + public record CustomerOption(String customerId, String customerName) {} + + public record WarehouseOption( + String warehouseId, String warehouseCode, String warehouseName, String countryCode) {} + + public record History( + String operationType, String fromStatus, String toStatus, String operatorId, + String idempotencyKey, String changeDetail, String remark, + LocalDateTime createdAt) {} + + public record Detail( + Summary summary, String productUrl, String receivingAddress, + String contactName, String contactPhone, + String domesticCarrier, String domesticTrackingNo, + String overseasCarrier, String overseasTrackingNo, + LocalDateTime estimatedArrivalAt, String remark, + String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt, + List histories) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java new file mode 100644 index 000000000..0a0c52dd9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.procurement.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; +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_overseas_procurement_order") +public class OverseasProcurementOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String customerName; + private String orderNo; + private String purchaseCategory; + private String productName; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productSpecification; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productUrl; + private Long purchaseQuantity; + private BigDecimal budgetUnitPrice; + private String currency; + private String receivingAddress; + private String contactName; + private String contactPhone; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName; + private Long inboundQuantity; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasTrackingNo; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt; + private String status; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private String reviewedBy; + private String reviewRemark; + private String reviewIdempotencyKey; + private String reviewRequestHash; + private LocalDateTime reviewedAt; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java new file mode 100644 index 000000000..e7d3be60d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.procurement.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; + +@Data +@TableName("ll_overseas_procurement_status_history") +public class OverseasProcurementStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String operationType; + private String fromStatus; + private String toStatus; + private String operatorId; + private String idempotencyKey; + private String changeDetail; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java new file mode 100644 index 000000000..94975c7e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.procurement.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +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 OverseasProcurementOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1") + OverseasProcurementOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); + + @Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + OverseasProcurementOrder findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE") + OverseasProcurementOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); + + @Select("") + List customerOptions(@Param("keyword") String keyword, @Param("limit") int limit); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java new file mode 100644 index 000000000..b35f731e1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.procurement.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; +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 OverseasProcurementStatusHistoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_overseas_procurement_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id") + List listOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java new file mode 100644 index 000000000..6a08ae5aa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -0,0 +1,379 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.quote.service.PartnerWarehouseDirectory; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; +import com.baiyue.lastleg.security.OverseasPermissions; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.HexFormat; +import java.util.Objects; +import java.util.Set; + +@Service +public class OverseasProcurementService { + public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; + public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; + public static final String PENDING_REVIEW = "PENDING_REVIEW"; + public static final String APPROVED = "APPROVED"; + public static final String REJECTED = "REJECTED"; + private static final Set STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED); + + @Resource private OverseasProcurementOrderMapper orders; + @Resource private OverseasProcurementStatusHistoryMapper histories; + @Resource private PartnerWarehouseDirectory warehouseDirectory; + @Resource private ObjectMapper objectMapper; + + @Transactional + public OverseasProcurementResponses.Detail create(String customerId, String operatorId, + OverseasProcurementRequests.Create request) { + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + OverseasProcurementOrder replay = orders.findKey(customerId, key); + if (replay != null) return replay(customerId, replay, requestHash); + + LocalDateTime now = LocalDateTime.now(); + OverseasProcurementOrder order = new OverseasProcurementOrder(); + order.setId(IdWorker.getId()); + order.setCustomerId(customerId); + order.setCustomerName(trim(request.customerName())); + order.setOrderNo("HC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId()); + order.setPurchaseCategory(request.purchaseCategory().trim()); + order.setProductName(request.productName().trim()); + order.setProductSpecification(trim(request.productSpecification())); + order.setProductUrl(trim(request.productUrl())); + order.setPurchaseQuantity(request.purchaseQuantity()); + order.setBudgetUnitPrice(request.budgetUnitPrice()); + order.setCurrency(defaultCurrency(request.currency())); + order.setReceivingAddress(trim(request.receivingAddress())); + order.setContactName(request.contactName().trim()); + order.setContactPhone(request.contactPhone().trim()); + order.setInboundQuantity(0L); + order.setStatus(PENDING_REVIEW); + order.setRemark(trim(request.remark())); + order.setIdempotencyKey(key); + order.setRequestHash(requestHash); + order.setCreatedBy(operatorId); + order.setVersion(0L); + order.setCreatedAt(now); + order.setUpdatedAt(now); + try { + orders.insert(order); + } catch (DuplicateKeyException exception) { + OverseasProcurementOrder concurrent = orders.findKey(customerId, key); + if (concurrent != null) return replay(customerId, concurrent, requestHash); + throw exception; + } + history(order, "CREATE", null, PENDING_REVIEW, operatorId, + key, null, request.remark(), now); + return detail(customerId, order.getOrderNo()); + } + + public List customerOptions(String keyword, int limit) { + if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200"); + return orders.customerOptions(trim(keyword), limit).stream() + .map(value -> new OverseasProcurementResponses.CustomerOption(value.getCustomerId(), + Objects.requireNonNullElse(trim(value.getCustomerName()), value.getCustomerId()))) + .toList(); + } + + public List warehouseOptions() { + return warehouseDirectory.options().stream() + .map(value -> new OverseasProcurementResponses.WarehouseOption(value.warehouseId(), + value.warehouseCode(), value.warehouseName(), value.countryCode())) + .toList(); + } + + public PageResult page( + String customerId, OverseasProcurementRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), OverseasProcurementOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.orderNo()), OverseasProcurementOrder::getOrderNo, query.orderNo()) + .and(StringUtils.hasText(query.customerKeyword()), value -> value + .like(OverseasProcurementOrder::getCustomerId, query.customerKeyword()) + .or().like(OverseasProcurementOrder::getCustomerName, query.customerKeyword())) + .like(StringUtils.hasText(query.purchaseCategory()), OverseasProcurementOrder::getPurchaseCategory, + query.purchaseCategory()) + .like(StringUtils.hasText(query.productName()), OverseasProcurementOrder::getProductName, + query.productName()) + .eq(StringUtils.hasText(query.status()), OverseasProcurementOrder::getStatus, + StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null) + .eq(StringUtils.hasText(query.purchaseWarehouseId()), OverseasProcurementOrder::getPurchaseWarehouseId, + query.purchaseWarehouseId()) + .ge(query.createdFrom() != null, OverseasProcurementOrder::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, OverseasProcurementOrder::getCreatedAt, query.createdTo()) + .orderByDesc(OverseasProcurementOrder::getCreatedAt) + .orderByDesc(OverseasProcurementOrder::getId); + Page result = orders.selectPage(Page.of(query.page(), query.size()), wrapper); + return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), query.page(), query.size(), + result.getTotal(), result.getPages()); + } + + public OverseasProcurementResponses.Detail detail(String customerId, String orderNo) { + OverseasProcurementOrder order = orders.findNo(customerId, orderNo); + if (order == null) throw notFound("Overseas procurement order not found"); + return new OverseasProcurementResponses.Detail(summary(order), order.getProductUrl(), + order.getReceivingAddress(), order.getContactName(), order.getContactPhone(), + order.getDomesticCarrier(), order.getDomesticTrackingNo(), order.getOverseasCarrier(), + order.getOverseasTrackingNo(), order.getEstimatedArrivalAt(), order.getRemark(), + order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(), + histories.listOrder(customerId, order.getId()).stream().map(value -> + new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(), + value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), + value.getChangeDetail(), value.getRemark(), + value.getCreatedAt())).toList()); + } + + @Transactional + public OverseasProcurementResponses.Detail review(String customerId, String orderNo, String operatorId, + OverseasProcurementRequests.Review request) { + String next = switch (request.decision().trim().toUpperCase()) { + case "APPROVE", APPROVED -> APPROVED; + case "REJECT", REJECTED -> REJECTED; + default -> throw badRequest("Review decision must be APPROVE or REJECT"); + }; + OverseasProcurementOrder order = lock(customerId, orderNo); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + if (key.equals(order.getReviewIdempotencyKey())) { + if (!requestHash.equals(order.getReviewRequestHash())) { + throw conflict("Review idempotency key was reused with a different payload"); + } + return detail(customerId, orderNo); + } + if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed"); + LocalDateTime now = LocalDateTime.now(); + order.setStatus(next); + order.setReviewedBy(operatorId); + order.setReviewRemark(trim(request.remark())); + order.setReviewIdempotencyKey(key); + order.setReviewRequestHash(requestHash); + order.setReviewedAt(now); + touch(order, now); + orders.updateById(order); + history(order, "REVIEW", PENDING_REVIEW, next, operatorId, + key, null, request.remark(), now); + return detail(customerId, orderNo); + } + + @Transactional + public OverseasProcurementResponses.Detail update(String customerId, String orderNo, String operatorId, + OverseasProcurementRequests.Update request) { + OverseasProcurementOrder order = lock(customerId, orderNo); + if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); + Map before = editableSnapshot(order); + if (request.inboundQuantity() != null) { + if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock"); + order.setInboundQuantity(request.inboundQuantity()); + } + setIfText(request.purchaseCategory(), order::setPurchaseCategory); + setIfText(request.productName(), order::setProductName); + patchText(request.productSpecification(), 500, "productSpecification", order::setProductSpecification); + patchText(request.productUrl(), 2000, "productUrl", order::setProductUrl); + patchWarehouse(order, request.purchaseWarehouseId(), request.purchaseWarehouseName()); + patchText(request.domesticCarrier(), 128, "domesticCarrier", order::setDomesticCarrier); + patchText(request.domesticTrackingNo(), 128, "domesticTrackingNo", order::setDomesticTrackingNo); + patchText(request.overseasCarrier(), 128, "overseasCarrier", order::setOverseasCarrier); + patchText(request.overseasTrackingNo(), 128, "overseasTrackingNo", order::setOverseasTrackingNo); + patchDateTime(request.estimatedArrivalAt(), order::setEstimatedArrivalAt); + patchText(request.remark(), 1000, "remark", order::setRemark); + Map after = editableSnapshot(order); + if (before.equals(after)) return detail(customerId, orderNo); + LocalDateTime now = LocalDateTime.now(); + touch(order, now); + orders.updateById(order); + history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, + null, changeDetail(before, after), null, now); + return detail(customerId, orderNo); + } + + private OverseasProcurementResponses.Detail replay(String customerId, OverseasProcurementOrder order, String hash) { + if (!hash.equals(order.getRequestHash())) { + throw conflict("Idempotency key was reused with different procurement data"); + } + return detail(customerId, order.getOrderNo()); + } + + private OverseasProcurementOrder lock(String customerId, String orderNo) { + OverseasProcurementOrder order = orders.lockNo(customerId, orderNo); + if (order == null) throw notFound("Overseas procurement order not found"); + return order; + } + + private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) { + long inbound = value(order.getInboundQuantity()); + return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(), + order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(), + order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(), + order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, inbound, + order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); + } + + private void history(OverseasProcurementOrder order, String operationType, String from, String to, + String operator, String idempotencyKey, String changeDetail, + String remark, LocalDateTime now) { + OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory(); + history.setId(IdWorker.getId()); + history.setOrderId(order.getId()); + history.setCustomerId(order.getCustomerId()); + history.setOperationType(operationType); + history.setFromStatus(from); + history.setToStatus(to); + history.setOperatorId(operator); + history.setIdempotencyKey(trim(idempotencyKey)); + history.setChangeDetail(changeDetail); + history.setRemark(trim(remark)); + history.setCreatedAt(now); + histories.insert(history); + } + + private void validateQuery(OverseasProcurementRequests.Query query) { + if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status().trim().toUpperCase())) { + throw badRequest("Unsupported procurement status"); + } + if (query.createdFrom() != null && query.createdTo() != null + && query.createdFrom().isAfter(query.createdTo())) throw badRequest("createdFrom must not be after createdTo"); + } + + private String hash(Object value) { + try { + byte[] json = objectMapper.writeValueAsBytes(value); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("Cannot hash procurement request", exception); + } + } + + private void patchWarehouse(OverseasProcurementOrder order, JsonNode warehouseId, JsonNode warehouseName) { + if (warehouseId == null) { + if (warehouseName != null) throw badRequest("purchaseWarehouseName cannot be changed without purchaseWarehouseId"); + return; + } + String id = nullableText(warehouseId, 128, "purchaseWarehouseId"); + if (id == null) { + order.setPurchaseWarehouseId(null); + order.setPurchaseWarehouseName(null); + return; + } + long numericId; + try { + numericId = Long.parseLong(id); + } catch (NumberFormatException exception) { + throw badRequest("purchaseWarehouseId must be a numeric overseas partner warehouse ID"); + } + var option = warehouseDirectory.required(numericId); + order.setPurchaseWarehouseId(option.warehouseId()); + order.setPurchaseWarehouseName(option.warehouseName()); + } + + private static void patchText(JsonNode node, int maxLength, String field, + java.util.function.Consumer setter) { + if (node != null) setter.accept(nullableText(node, maxLength, field)); + } + + private static String nullableText(JsonNode node, int maxLength, String field) { + if (node.isNull()) return null; + if (!node.isTextual()) throw badRequest(field + " must be a string or null"); + String value = trim(node.textValue()); + if (value != null && value.length() > maxLength) throw badRequest(field + " exceeds max length " + maxLength); + return value; + } + + private static void patchDateTime(JsonNode node, java.util.function.Consumer setter) { + if (node == null) return; + String value = nullableText(node, 64, "estimatedArrivalAt"); + if (value == null) { + setter.accept(null); + return; + } + try { + setter.accept(LocalDateTime.parse(value)); + } catch (DateTimeParseException exception) { + throw badRequest("estimatedArrivalAt must be an ISO local date-time or null"); + } + } + + private static Map editableSnapshot(OverseasProcurementOrder order) { + Map result = new LinkedHashMap<>(); + result.put("purchaseCategory", order.getPurchaseCategory()); + result.put("productName", order.getProductName()); + result.put("productSpecification", order.getProductSpecification()); + result.put("productUrl", order.getProductUrl()); + result.put("inboundQuantity", value(order.getInboundQuantity())); + result.put("purchaseWarehouseId", order.getPurchaseWarehouseId()); + result.put("purchaseWarehouseName", order.getPurchaseWarehouseName()); + result.put("domesticCarrier", order.getDomesticCarrier()); + result.put("domesticTrackingNo", order.getDomesticTrackingNo()); + result.put("overseasCarrier", order.getOverseasCarrier()); + result.put("overseasTrackingNo", order.getOverseasTrackingNo()); + result.put("estimatedArrivalAt", order.getEstimatedArrivalAt()); + result.put("remark", order.getRemark()); + return result; + } + + private String changeDetail(Map before, Map after) { + Map changes = new LinkedHashMap<>(); + before.forEach((field, oldValue) -> { + Object newValue = after.get(field); + if (!Objects.equals(oldValue, newValue)) { + Map pair = new LinkedHashMap<>(); + pair.put("before", oldValue); + pair.put("after", newValue); + changes.put(field, pair); + } + }); + try { + return objectMapper.writeValueAsString(changes); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize procurement change detail", exception); + } + } + + private static void touch(OverseasProcurementOrder order, LocalDateTime now) { + order.setVersion(value(order.getVersion()) + 1); + order.setUpdatedAt(now); + } + + private static long value(Long value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String defaultCurrency(String value) { return trim(value) == null ? "USD" : value.trim().toUpperCase(); } + private static void setIfText(String value, java.util.function.Consumer setter) { + if (StringUtils.hasText(value)) setter.accept(value.trim()); + } + private static ResponseStatusException badRequest(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + private static ResponseStatusException notFound(String message) { + return new ResponseStatusException(HttpStatus.NOT_FOUND, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java new file mode 100644 index 000000000..ab033a5d3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.relabel.mapper") +public class RelabelMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java new file mode 100644 index 000000000..d878be140 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.relabel.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/relabel-applications") +public class MerchantRelabelApplicationController { + @Resource private RelabelApplicationService service; + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid RelabelRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } + @GetMapping("/{relabelNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); } + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java new file mode 100644 index 000000000..fa5482e72 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.relabel.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/relabel-applications") +public class RelabelApplicationController { + @Resource private RelabelApplicationService service; + + @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')") + @Operation(summary = "Create a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_CREATE) + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, + @Valid @RequestBody RelabelRequests.Create request) { + requirePlatform(user); + return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request)); + } + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + @Operation(summary = "Page relabel applications", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST) + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid RelabelRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.page(null, query)); + } + @GetMapping("/{relabelNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + @Operation(summary = "Get a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST) + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo) { + requirePlatform(user); + return ApiResponse.success(service.detail(null, relabelNo)); + } + @PutMapping("/{relabelNo}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") + @Operation(summary = "Change relabel application status", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS) + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, + @Valid @RequestBody RelabelRequests.ChangeStatus request) { + requirePlatform(user); + return ApiResponse.success(service.changeStatus(null, relabelNo, operator(user), request)); + } + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); + } + } + private String requiredCustomer(String customerId) { + String value = customerId == null ? null : customerId.trim(); + if (value == null || value.isEmpty() || "0".equals(value)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required"); + } + return value; + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java new file mode 100644 index 000000000..32ddaea7c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.relabel.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class RelabelRequests { + private RelabelRequests() {} + + public record Create( + @NotBlank @Size(max = 128) String customerOrderNo, + @NotBlank @Size(max = 128) String overseasWarehouseId, + @NotNull LocalDateTime labelDeadline, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 64) String contactPhone, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Item> items + ) {} + + public record Item( + @NotBlank @Size(max = 128) String skuCode, + @Size(max = 128) String productImageFileId, + @Size(max = 255) String productImageFileName, + @Size(max = 1000) String productImageUrl, + @NotBlank @Size(max = 255) String productNameZh, + @Positive int totalPackageCount, + @Positive int notifiedPackageCount, + @NotBlank @Size(max = 128) String shipmentNo, + @Size(max = 255) String trackingCode, + @NotBlank @Size(max = 128) String newSkuCode, + @Size(max = 1000) String labelRequirement, + @Size(max = 128) String relabelFileId, + @Size(max = 255) String relabelFileName, + @Size(max = 1000) String relabelFileUrl + ) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo, + String customerOrderNo, String skuCode, String status, + String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record ChangeStatus(@NotBlank @Size(max = 32) String status, + @Size(max = 1000) String remark) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java new file mode 100644 index 000000000..f148026a7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.relabel.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class RelabelResponses { + private RelabelResponses() {} + + public record Summary(Long id, String relabelNo, Long lastlegOrderId, String lastlegOrderNo, + String customerOrderNo, String overseasWarehouseId, LocalDateTime labelDeadline, + String contactName, String contactPhone, String status, int itemCount, + int totalPackageCount, int notifiedPackageCount, String skuCodes, + LocalDateTime createdAt) {} + public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, + String productImageUrl, String productNameZh, int totalPackageCount, + int notifiedPackageCount, String shipmentNo, String trackingCode, + String newSkuCode, String labelRequirement, String relabelFileId, + String relabelFileName, String relabelFileUrl) {} + public record History(String fromStatus, String toStatus, String operatorId, + String remark, LocalDateTime createdAt) {} + public record Detail(Summary application, String remark, List items, List history) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java new file mode 100644 index 000000000..607c2f25f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.relabel.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; + +@Data +@TableName("ll_relabel_application") +public class RelabelApplication { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String relabelNo; + private Long lastlegOrderId; + private String customerOrderNo; + private String overseasWarehouseId; + private LocalDateTime labelDeadline; + private String contactName; + private String contactPhone; + private String remark; + private String status; + private Integer itemCount; + private Integer totalPackageCount; + private Integer notifiedPackageCount; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java new file mode 100644 index 000000000..5dde51a26 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.relabel.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; + +@Data +@TableName("ll_relabel_item") +public class RelabelItem { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private String skuCode; + private String productImageFileId; + private String productImageFileName; + private String productImageUrl; + private String productNameZh; + private Integer totalPackageCount; + private Integer notifiedPackageCount; + private String shipmentNo; + private String trackingCode; + private String newSkuCode; + private String labelRequirement; + private String relabelFileId; + private String relabelFileName; + private String relabelFileUrl; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java new file mode 100644 index 000000000..d68d2dda5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.relabel.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; + +@Data +@TableName("ll_relabel_status_history") +public class RelabelStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long applicationId; + private String fromStatus; + private String toStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java new file mode 100644 index 000000000..d082ee801 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface RelabelApplicationMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1") + RelabelApplication findNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); + + @Select("SELECT * FROM ll_relabel_application WHERE relabel_no=#{relabelNo} LIMIT 1") + RelabelApplication findAnyByRelabelNo(@Param("relabelNo") String relabelNo); + + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + RelabelApplication findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") + RelabelApplication lockNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); + + @Select("SELECT * FROM ll_relabel_application WHERE relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") + RelabelApplication lockAnyByRelabelNo(@Param("relabelNo") String relabelNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java new file mode 100644 index 000000000..1e9b91c39 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RelabelItemMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java new file mode 100644 index 000000000..740e3b61c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RelabelStatusHistoryMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java new file mode 100644 index 000000000..893536c8e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.pda; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.relabel.pda.mapper") +public class PdaRelabelMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java new file mode 100644 index 000000000..6d89ac391 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -0,0 +1,127 @@ +package com.baiyue.lastleg.relabel.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses; +import com.baiyue.lastleg.relabel.pda.service.PdaRelabelService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/relabel-tasks") +@PreAuthorize("hasAuthority('lastleg:pda:operate')") +public class PdaRelabelController { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + private static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER"; + + @Resource private PdaRelabelService service; + + @GetMapping("/candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user) { + Identity identity = identity(user); + return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); + } + + @GetMapping("/{relabelNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo) { + Identity identity = identity(user); + return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), relabelNo)); + } + + @PostMapping("/{relabelNo}/claim") + public ApiResponse claim(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Claim request) { + Identity identity = identity(user); + return ApiResponse.success(service.claim(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/units") + public ApiResponse scan(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Scan request) { + Identity identity = identity(user); + return ApiResponse.success(service.scan(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/units/skip") + public ApiResponse skip(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Skip request) { + Identity identity = identity(user); + return ApiResponse.success(service.skip(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/exceptions") + public ApiResponse exception(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.ExceptionFact request) { + Identity identity = identity(user); + return ApiResponse.success(service.exception(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping(value = "/{relabelNo}/photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @RequestPart("file") MultipartFile file) { + Identity identity = identity(user); + return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), identity.overseasWarehouseId(), + identity.userId(), relabelNo, file)); + } + + @PostMapping("/{relabelNo}/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) { + Identity identity = identity(user); + return ApiResponse.success(service.complete(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/review") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Review request) { + Identity identity = identity(user); + return ApiResponse.success(service.review(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + static Identity identity(AuthenticatedUser user) { + if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0L + || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "Authenticated platform warehouse PDA identity is required"); + } + if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION) + || !user.permissions().contains(PICKER_ROLE)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "WAREHOUSE_PICKER and " + OPERATE_PERMISSION + " are required"); + } + if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "PDA operator must be bound to one overseas warehouse"); + } + String operator = String.valueOf(user.userId()); + return new Identity(user.userId(), operator, + user.username() == null || user.username().isBlank() ? operator : user.username(), + user.overseasWarehouseId()); + } + + record Identity(long userId, String operatorKey, String operatorName, long overseasWarehouseId) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java new file mode 100644 index 000000000..c363ac059 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.relabel.pda.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class PdaRelabelRequests { + private PdaRelabelRequests() { } + + public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Scan(@NotNull @Positive Long itemId, + @NotBlank @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 255) String newLabel, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Skip(@NotNull @Positive Long itemId, + @NotBlank @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 1000) String reason, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record ExceptionFact(@NotNull @Positive Long itemId, + @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 32) String exceptionCode, + @NotBlank @Size(max = 1000) String reason, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Complete(@NotEmpty @Size(max = 20) List<@Positive Long> photoFileIds, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { } + + public record Review(@NotBlank @Size(max = 16) String decision, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java new file mode 100644 index 000000000..72724a325 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.relabel.pda.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaRelabelResponses { + private PdaRelabelResponses() { } + + public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, + String applicationStatus, String taskStatus, int itemCount, + int expectedPackages, int relabelledPackages, int skippedPackages, + int exceptionPackages, int version, boolean claimedByCurrentOperator) { } + + public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, + String contactName, String contactPhone, String applicationStatus, + String taskStatus, int expectedPackages, int relabelledPackages, + int skippedPackages, int exceptionPackages, int version, + String operatorName, String reviewerName, List completionPhotoFileIds, + String completionRemark, String reviewDecision, String reviewRemark, + LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt, + List items, List unitFacts, List history) { } + + public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, + String trackingCode, String newSkuCode, String labelRequirement, + String relabelFileId, String relabelFileName, String relabelFileUrl, + int expectedPackages, int relabelledPackages, int skippedPackages, + int exceptionPackages) { } + + public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, + String result, String exceptionCode, String reason, + String operatorName, LocalDateTime occurredAt) { } + + public record History(String action, String fromStatus, String toStatus, + String operatorName, String detail, LocalDateTime createdAt) { } + + public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java new file mode 100644 index 000000000..674a07be8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.relabel.pda.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; + +@Data +@TableName("ll_relabel_pda_history") +public class PdaRelabelHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private Long applicationId; + private String customerId; + private String action; + private String fromStatus; + private String toStatus; + private String operatorKey; + private String operatorName; + private String detailJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java new file mode 100644 index 000000000..991bbea9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.relabel.pda.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; + +@Data +@TableName("ll_relabel_pda_operation") +public class PdaRelabelOperation { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private String customerId; + private String operatorKey; + private String action; + private String idempotencyKey; + private String requestHash; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java new file mode 100644 index 000000000..f2e07ea86 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.relabel.pda.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; + +@Data +@TableName("ll_relabel_pda_task") +public class PdaRelabelTask { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private String relabelNo; + private Long overseasWarehouseId; + private String status; + private String operatorKey; + private String operatorName; + private String reviewerKey; + private String reviewerName; + private Integer expectedPackages; + private Integer relabelledPackages; + private Integer skippedPackages; + private Integer exceptionPackages; + private String completionPhotoFileIds; + private String completionRemark; + private String reviewDecision; + private String reviewRemark; + private LocalDateTime claimedAt; + private LocalDateTime submittedAt; + private LocalDateTime reviewedAt; + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java new file mode 100644 index 000000000..a6ece68da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.relabel.pda.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; + +@Data +@TableName("ll_relabel_pda_unit_fact") +public class PdaRelabelUnitFact { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private Long applicationId; + private Long itemId; + private String customerId; + private String oldLabel; + private String newLabel; + private String result; + private String exceptionCode; + private String reason; + private String operatorKey; + private String operatorName; + private LocalDateTime occurredAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java new file mode 100644 index 000000000..d693674a3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java @@ -0,0 +1,116 @@ +package com.baiyue.lastleg.relabel.pda.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +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; + +@Component +public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_RELABEL_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getRelabelPhotoFile()); + } + + @Override + public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { + long tenantId = tenantId(customerId); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new IllegalArgumentException("Cannot read relabel photo", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId, BUSINESS_TYPE, taskId) + .header("X-Internal-Service-Token", internalToken) + .header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Relabel photo upload failed"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Relabel photo upload failed"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "File service returned no relabel photo metadata"); + } + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0L : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long taskId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Relabel photo does not exist or its ownership is invalid"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Relabel photo does not exist or its ownership is invalid"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() != fileId + || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) + || !String.valueOf(taskId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Relabel photo metadata does not match the PDA task"); + } + } + + private static long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Customer ID must be numeric", exception); + } + } + + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private record MetadataEnvelope(String code, String message, Metadata data) { } + private 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/relabel/pda/gateway/RelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java new file mode 100644 index 000000000..c309f5dd8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.relabel.pda.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface RelabelPhotoFileGateway { + UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file); + void validate(String customerId, long taskId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java new file mode 100644 index 000000000..ccd126e40 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PdaRelabelHistoryMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java new file mode 100644 index 000000000..8e96b2050 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaRelabelOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_operation WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaRelabelOperation find(@Param("operatorKey") String operatorKey, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java new file mode 100644 index 000000000..ee6b56521 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface PdaRelabelTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1") + PdaRelabelTask find(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + @Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE") + PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + @Update(""" + UPDATE ll_relabel_pda_task + SET status=#{task.status}, operator_key=#{task.operatorKey}, operator_name=#{task.operatorName}, + reviewer_key=#{task.reviewerKey}, reviewer_name=#{task.reviewerName}, + relabelled_packages=#{task.relabelledPackages}, skipped_packages=#{task.skippedPackages}, + exception_packages=#{task.exceptionPackages}, completion_photo_file_ids=#{task.completionPhotoFileIds}, + completion_remark=#{task.completionRemark}, review_decision=#{task.reviewDecision}, + review_remark=#{task.reviewRemark}, submitted_at=#{task.submittedAt}, reviewed_at=#{task.reviewedAt}, + version=version+1, updated_at=#{task.updatedAt} + WHERE id=#{task.id} AND customer_id=#{task.customerId} AND version=#{expectedVersion} + """) + int updateWithVersion(@Param("task") PdaRelabelTask task, @Param("expectedVersion") Integer expectedVersion); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java new file mode 100644 index 000000000..af9100c89 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaRelabelUnitFactMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_unit_fact WHERE old_label=#{oldLabel} LIMIT 1") + PdaRelabelUnitFact findOldLabel(@Param("oldLabel") String oldLabel); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java new file mode 100644 index 000000000..a9fc30a0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -0,0 +1,480 @@ +package com.baiyue.lastleg.relabel.pda.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +@Service +public class PdaRelabelService { + public static final String PROCESSING = "PROCESSING"; + public static final String WAIT_REVIEW = "WAIT_REVIEW"; + public static final String COMPLETED = "COMPLETED"; + private static final long MAX_PHOTO_SIZE = 10L * 1024 * 1024; + private static final Set EXCEPTION_CODES = Set.of( + "DAMAGED", "MISSING_LABEL", "LABEL_MISMATCH", "UNREADABLE", "OTHER"); + + @Resource private RelabelApplicationMapper applications; + @Resource private RelabelItemMapper items; + @Resource private PdaRelabelTaskMapper tasks; + @Resource private PdaRelabelUnitFactMapper unitFacts; + @Resource private PdaRelabelOperationMapper operations; + @Resource private PdaRelabelHistoryMapper histories; + @Resource private RelabelApplicationService applicationService; + @Resource private RelabelPhotoFileGateway photoFiles; + @Resource private ObjectMapper sourceObjectMapper; + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + objectMapper = sourceObjectMapper.copy().findAndRegisterModules() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + @Transactional(readOnly = true) + public List candidates(String operatorKey, long overseasWarehouseId) { + return applications.selectList(new LambdaQueryWrapper() + .eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId)) + .in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING) + .orderByAsc(RelabelApplication::getLabelDeadline) + .orderByAsc(RelabelApplication::getCreatedAt)) + .stream().map(application -> candidate(application, + tasks.find(application.getCustomerId(), application.getId()), operatorKey)).toList(); + } + + @Transactional(readOnly = true) + public PdaRelabelResponses.Detail detail(String operatorKey, long overseasWarehouseId, String relabelNo) { + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false); + return detail(application, tasks.find(application.getCustomerId(), application.getId())); + } + + @Transactional + public PdaRelabelResponses.Detail claim(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Claim request) { + ClaimCommand command = new ClaimCommand(relabelNo, request.idempotencyKey()); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "CLAIM", request.idempotencyKey(), command); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + if (!Set.of(RelabelApplicationService.PENDING_PROCESSING, PROCESSING).contains(application.getStatus())) { + throw conflict("Relabel application is not executable"); + } + PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); + if (task != null) { + if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) { + throw conflict("Relabel task is already claimed or awaiting review"); + } + } else { + LocalDateTime now = LocalDateTime.now(); + task = new PdaRelabelTask(); + task.setId(IdWorker.getId()); + task.setApplicationId(application.getId()); task.setCustomerId(application.getCustomerId()); + task.setRelabelNo(application.getRelabelNo()); task.setOverseasWarehouseId(overseasWarehouseId); + task.setStatus(PROCESSING); task.setOperatorKey(operatorKey); task.setOperatorName(operatorName); + task.setExpectedPackages(expectedPackages(application.getId())); + task.setRelabelledPackages(0); task.setSkippedPackages(0); task.setExceptionPackages(0); + task.setClaimedAt(now); task.setVersion(0); task.setCreatedAt(now); task.setUpdatedAt(now); + try { tasks.insert(task); } + catch (DuplicateKeyException duplicate) { throw conflict("Relabel task was claimed concurrently"); } + if (RelabelApplicationService.PENDING_PROCESSING.equals(application.getStatus())) { + applicationService.changeStatus(application.getCustomerId(), application.getRelabelNo(), operatorKey, + new RelabelRequests.ChangeStatus(PROCESSING, "PDA relabel task claimed")); + application.setStatus(PROCESSING); + } + insertHistory(task, "CLAIM", null, PROCESSING, operatorKey, operatorName, + Map.of("relabelNo", relabelNo)); + } + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "CLAIM", request.idempotencyKey(), command, result); + return result; + } + + @Transactional + public PdaRelabelResponses.Detail scan(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Scan request) { + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(), + request, request.itemId(), request.oldLabel(), request.newLabel(), "RELABELLED", null, null, + request.expectedVersion(), item -> { + if (!item.getNewSkuCode().equalsIgnoreCase(request.newLabel().trim())) { + throw conflict("Scanned new label does not match the approved new SKU"); + } + if (request.oldLabel().trim().equalsIgnoreCase(request.newLabel().trim())) { + throw conflict("Old and new labels must be different"); + } + }); + } + + @Transactional + public PdaRelabelResponses.Detail skip(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Skip request) { + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "SKIP", request.idempotencyKey(), + request, request.itemId(), request.oldLabel(), null, "SKIPPED", null, request.reason(), + request.expectedVersion(), ignored -> { }); + } + + @Transactional + public PdaRelabelResponses.Detail exception(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.ExceptionFact request) { + String code = request.exceptionCode().trim().toUpperCase(Locale.ROOT); + if (!EXCEPTION_CODES.contains(code)) throw badRequest("Unsupported relabel exception code"); + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "EXCEPTION", + request.idempotencyKey(), request, request.itemId(), trim(request.oldLabel()), null, + "EXCEPTION", code, request.reason(), request.expectedVersion(), ignored -> { }); + } + + public PdaRelabelResponses.Photo uploadPhoto(String operatorKey, long overseasWarehouseId, long operatorId, + String relabelNo, MultipartFile file) { + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, false); + if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel photos can only be uploaded while processing"); + if (file == null || file.isEmpty()) throw badRequest("Relabel photo is required"); + if (file.getSize() > MAX_PHOTO_SIZE) throw badRequest("Relabel photo exceeds 10 MB"); + if (file.getContentType() == null || !file.getContentType().toLowerCase(Locale.ROOT).startsWith("image/")) { + throw badRequest("Relabel photo must be an image"); + } + RelabelPhotoFileGateway.UploadedFile uploaded = photoFiles.upload(application.getCustomerId(), + task.getId(), operatorId, file); + return new PdaRelabelResponses.Photo(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + + @Transactional + public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Complete request) { + CompleteCommand command = new CompleteCommand(relabelNo, request); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "COMPLETE", request.idempotencyKey(), command); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); + requireVersion(task, request.expectedVersion()); + List detailItems = itemList(application); + List facts = factList(task); + for (RelabelItem item : detailItems) { + long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count(); + if (actual != value(item.getNotifiedPackageCount())) { + throw conflict("Every relabel item must have exactly one fact per notified package"); + } + } + List photoIds = request.photoFileIds().stream().distinct().toList(); + if (photoIds.size() != request.photoFileIds().size()) throw conflict("Relabel photo IDs must be unique"); + photoIds.forEach(fileId -> photoFiles.validate(application.getCustomerId(), task.getId(), fileId)); + String before = task.getStatus(); + task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds)); + task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now()); + updateTask(task, request.expectedVersion()); + insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName, + Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), ""))); + application.setStatus(PROCESSING); + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result); + return result; + } + + @Transactional + public PdaRelabelResponses.Detail review(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Review request) { + ReviewCommand command = new ReviewCommand(relabelNo, request); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "REVIEW", request.idempotencyKey(), command); + if (replay != null) return replay; + String decision = request.decision().trim().toUpperCase(Locale.ROOT); + if (!Set.of("APPROVE", "REWORK").contains(decision)) throw badRequest("Review decision must be APPROVE or REWORK"); + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + if (!PROCESSING.equals(application.getStatus())) { + throw conflict("Relabel application is no longer executable"); + } + PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); + if (task == null) throw notFound("PDA relabel task was not found"); + if (!Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId)) { + throw notFound("PDA relabel task was not found in the bound overseas warehouse"); + } + if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review"); + requireVersion(task, request.expectedVersion()); + String before = task.getStatus(); + String after = "APPROVE".equals(decision) ? COMPLETED : PROCESSING; + task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName); + task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark())); + task.setReviewedAt(LocalDateTime.now()); + updateTask(task, request.expectedVersion()); + if ("APPROVE".equals(decision)) { + applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey, + new RelabelRequests.ChangeStatus(COMPLETED, request.remark())); + application.setStatus(COMPLETED); + } + insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName, + Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), ""))); + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "REVIEW", request.idempotencyKey(), command, result); + return result; + } + + private PdaRelabelResponses.Detail unitAction(String operatorKey, String operatorName, + long overseasWarehouseId, String relabelNo, String action, String idempotencyKey, Object command, + Long itemId, String oldLabel, String newLabel, String resultCode, String exceptionCode, String reason, + Integer expectedVersion, Consumer validation) { + UnitCommand replayCommand = new UnitCommand(relabelNo, action, command); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + action, idempotencyKey, replayCommand); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); + requireVersion(task, expectedVersion); + RelabelItem item = requireItem(application, itemId); + validation.accept(item); + List existingFacts = factList(task); + long itemFacts = existingFacts.stream().filter(fact -> itemId.equals(fact.getItemId())).count(); + if (itemFacts >= value(item.getNotifiedPackageCount())) { + throw conflict("Relabel item already has all required package facts"); + } + String normalizedOldLabel = trim(oldLabel); + if (normalizedOldLabel != null && unitFacts.findOldLabel(normalizedOldLabel) != null) { + throw conflict("Old label was already processed by another relabel unit"); + } + PdaRelabelUnitFact fact = new PdaRelabelUnitFact(); + fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId()); + fact.setItemId(itemId); fact.setCustomerId(application.getCustomerId()); + fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(trim(newLabel)); fact.setResult(resultCode); + fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason)); + fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); + try { unitFacts.insert(fact); } + catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); } + if ("RELABELLED".equals(resultCode)) task.setRelabelledPackages(value(task.getRelabelledPackages()) + 1); + if ("SKIPPED".equals(resultCode)) task.setSkippedPackages(value(task.getSkippedPackages()) + 1); + if ("EXCEPTION".equals(resultCode)) task.setExceptionPackages(value(task.getExceptionPackages()) + 1); + updateTask(task, expectedVersion); + insertHistory(task, action, PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("factId", fact.getId(), "itemId", itemId, "result", resultCode)); + PdaRelabelResponses.Detail response = detail(application, task); + saveOperation(task, operatorKey, action, idempotencyKey, replayCommand, response); + return response; + } + + private RelabelApplication requireApplication(String relabelNo, long overseasWarehouseId, boolean lock) { + RelabelApplication application = lock ? applications.lockAnyByRelabelNo(relabelNo) + : applications.findAnyByRelabelNo(relabelNo); + if (application == null) throw notFound("Relabel application was not found"); + long applicationWarehouse; + try { applicationWarehouse = Long.parseLong(application.getOverseasWarehouseId()); } + catch (Exception invalid) { throw conflict("Relabel application has no valid overseas warehouse"); } + if (applicationWarehouse != overseasWarehouseId) { + throw notFound("Relabel application was not found in the bound overseas warehouse"); + } + return application; + } + + private PdaRelabelTask requireOwnedTask(RelabelApplication application, String operatorKey, boolean lock) { + if (!PROCESSING.equals(application.getStatus())) { + throw conflict("Relabel application is no longer executable"); + } + PdaRelabelTask task = lock ? tasks.lock(application.getCustomerId(), application.getId()) + : tasks.find(application.getCustomerId(), application.getId()); + if (task == null) throw conflict("Claim the relabel task before operating"); + if (!operatorKey.equals(task.getOperatorKey())) throw conflict("Relabel task is claimed by another operator"); + if (!Objects.equals(task.getOverseasWarehouseId(), Long.parseLong(application.getOverseasWarehouseId()))) { + throw conflict("Relabel task warehouse does not match the approved application"); + } + if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel task is not in processing status"); + return task; + } + + private RelabelItem requireItem(RelabelApplication application, Long itemId) { + RelabelItem item = items.selectOne(new LambdaQueryWrapper() + .eq(RelabelItem::getId, itemId).eq(RelabelItem::getApplicationId, application.getId()) + .eq(RelabelItem::getCustomerId, application.getCustomerId())); + if (item == null) throw notFound("Relabel item does not belong to the current application"); + return item; + } + + private void requireVersion(PdaRelabelTask task, Integer expectedVersion) { + if (!Objects.equals(task.getVersion(), expectedVersion)) { + throw conflict("Relabel task version changed; reload before retrying"); + } + } + + private void updateTask(PdaRelabelTask task, Integer expectedVersion) { + task.setUpdatedAt(LocalDateTime.now()); + if (tasks.updateWithVersion(task, expectedVersion) != 1) { + throw conflict("Relabel task version changed concurrently"); + } + task.setVersion(expectedVersion + 1); + } + + private int expectedPackages(Long applicationId) { + return itemList(applicationId).stream().mapToInt(item -> value(item.getNotifiedPackageCount())).sum(); + } + + private List itemList(RelabelApplication application) { return itemList(application.getId()); } + private List itemList(Long applicationId) { + return items.selectList(new LambdaQueryWrapper() + .eq(RelabelItem::getApplicationId, applicationId).orderByAsc(RelabelItem::getId)); + } + + private List factList(PdaRelabelTask task) { + return unitFacts.selectList(new LambdaQueryWrapper() + .eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId()) + .eq(PdaRelabelUnitFact::getTaskId, task.getId()) + .orderByAsc(PdaRelabelUnitFact::getOccurredAt).orderByAsc(PdaRelabelUnitFact::getId)); + } + + private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task, + String operatorKey) { + return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(), + application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(), + value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), + task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), + task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), + task != null && operatorKey.equals(task.getOperatorKey())); + } + + private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) { + List detailItems = itemList(application); + List facts = task == null ? List.of() : factList(task); + Map> factsByItem = facts.stream() + .collect(Collectors.groupingBy(PdaRelabelUnitFact::getItemId, LinkedHashMap::new, Collectors.toList())); + List itemResponses = detailItems.stream().map(item -> { + List itemFacts = factsByItem.getOrDefault(item.getId(), List.of()); + return new PdaRelabelResponses.Item(item.getId(), item.getSkuCode(), item.getProductNameZh(), + item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(), + item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(), + value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"), + count(itemFacts, "EXCEPTION")); + }).toList(); + List factResponses = facts.stream().map(fact -> + new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), + fact.getResult(), fact.getExceptionCode(), fact.getReason(), fact.getOperatorName(), fact.getOccurredAt())).toList(); + List historyResponses = task == null ? List.of() : histories.selectList( + new LambdaQueryWrapper().eq(PdaRelabelHistory::getCustomerId, application.getCustomerId()) + .eq(PdaRelabelHistory::getTaskId, task.getId()) + .orderByAsc(PdaRelabelHistory::getCreatedAt).orderByAsc(PdaRelabelHistory::getId)).stream() + .map(history -> new PdaRelabelResponses.History(history.getAction(), history.getFromStatus(), + history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList(); + return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(), + application.getLabelDeadline(), application.getContactName(), application.getContactPhone(), + application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(), + task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), + task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), + task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), + task == null ? null : task.getOperatorName(), task == null ? null : task.getReviewerName(), + task == null ? List.of() : readIds(task.getCompletionPhotoFileIds()), + task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(), + task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(), + task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(), + itemResponses, factResponses, historyResponses); + } + + private void insertHistory(PdaRelabelTask task, String action, String from, String to, + String operatorKey, String operatorName, Object detail) { + PdaRelabelHistory history = new PdaRelabelHistory(); + history.setId(IdWorker.getId()); history.setTaskId(task.getId()); + history.setApplicationId(task.getApplicationId()); history.setCustomerId(task.getCustomerId()); + history.setAction(action); history.setFromStatus(from); history.setToStatus(to); + history.setOperatorKey(operatorKey); history.setOperatorName(operatorName); + history.setDetailJson(write(detail)); history.setCreatedAt(LocalDateTime.now()); + histories.insert(history); + } + + private PdaRelabelResponses.Detail replay(String operatorKey, long overseasWarehouseId, String relabelNo, + String action, String key, Object request) { + PdaRelabelOperation operation = operations.find(operatorKey, idempotencyKey(key)); + if (operation == null) return null; + PdaRelabelTask task = tasks.selectById(operation.getTaskId()); + if (task == null || !Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId) + || !Objects.equals(task.getRelabelNo(), relabelNo) + || !Objects.equals(task.getCustomerId(), operation.getCustomerId())) { + throw notFound("PDA relabel operation was not found in the bound overseas warehouse"); + } + if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different relabel request"); + } + try { return objectMapper.readValue(operation.getResultJson(), PdaRelabelResponses.Detail.class); } + catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read PDA relabel replay", exception); } + } + + private void saveOperation(PdaRelabelTask task, String operatorKey, String action, String key, + Object request, PdaRelabelResponses.Detail result) { + PdaRelabelOperation operation = new PdaRelabelOperation(); + operation.setId(IdWorker.getId()); operation.setTaskId(task.getId()); operation.setCustomerId(task.getCustomerId()); + operation.setOperatorKey(operatorKey); operation.setAction(action); operation.setIdempotencyKey(idempotencyKey(key)); + operation.setRequestHash(hash(request)); operation.setResultJson(write(result)); operation.setCreatedAt(LocalDateTime.now()); + try { operations.insert(operation); } + catch (DuplicateKeyException duplicate) { throw conflict("Relabel operation was submitted concurrently"); } + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { throw new IllegalStateException(exception); } + } + + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + + private List readIds(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { + return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class)); + } catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel photo IDs", exception); } + } + + private static int count(List facts, String result) { + return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count(); + } + private static int value(Integer value) { return value == null ? 0 : value; } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String idempotencyKey(String value) { return value == null ? null : value.trim(); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + + private record ClaimCommand(String relabelNo, String idempotencyKey) { } + private record UnitCommand(String relabelNo, String action, Object request) { } + private record CompleteCommand(String relabelNo, PdaRelabelRequests.Complete request) { } + private record ReviewCommand(String relabelNo, PdaRelabelRequests.Review request) { } +} 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 new file mode 100644 index 000000000..c9bce8008 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -0,0 +1,272 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class RelabelApplicationService { + public static final String PENDING_PROCESSING = "PENDING_PROCESSING"; + private static final Set STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED"); + + @Resource private RelabelApplicationMapper applications; + @Resource private RelabelItemMapper items; + @Resource private RelabelStatusHistoryMapper histories; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderStatusHistoryMapper orderHistories; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private ObjectMapper objectMapper; + + @Transactional + public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { + int total = 0; + int notified = 0; + for (RelabelRequests.Item item : request.items()) { + if (item.notifiedPackageCount() > item.totalPackageCount()) { + throw badRequest("Notified relocation packages cannot exceed total packages for SKU " + item.skuCode()); + } + total = Math.addExact(total, item.totalPackageCount()); + notified = Math.addExact(notified, item.notifiedPackageCount()); + } + String requestHash = hash(request); + RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim()); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data"); + return detail(customerId, replay.getRelabelNo()); + } + LocalDateTime now = LocalDateTime.now(); + long applicationId = IdWorker.getId(); + String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId; + LastlegOrder order = new LastlegOrder(); + order.setId(IdWorker.getId()); + order.setOrderNo(orderNoGenerator.next()); + order.setCustomerId(customerId); + order.setOrderType("RELABEL_APPLICATION"); + order.setInboundCode(relabelNo); + order.setSourceRegion("CUSTOMER"); + order.setSourceInboundId(relabelNo); + order.setSourceOrderNo(request.customerOrderNo().trim()); + order.setOverseasWarehouseId(request.overseasWarehouseId().trim()); + order.setPalletCount(0); + order.setPackageCount(total); + order.setPickedPackageCount(0); + order.setShippedPackageCount(0); + order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); + order.setRelabelOrderNo(relabelNo); + order.setRelabelStatus(PENDING_PROCESSING); + order.setCustomerRemark(trim(request.remark())); + order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo); + order.setSourceLoadingPlanId("RELABEL_APPLICATION"); + order.setSourcePayloadHash("sha256:" + requestHash); + order.setCreatedAt(now); + order.setUpdatedAt(now); + orders.insert(order); + + RelabelApplication application = new RelabelApplication(); + application.setId(applicationId); + application.setCustomerId(customerId); + application.setRelabelNo(relabelNo); + application.setLastlegOrderId(order.getId()); + application.setCustomerOrderNo(request.customerOrderNo().trim()); + application.setOverseasWarehouseId(request.overseasWarehouseId().trim()); + application.setLabelDeadline(request.labelDeadline()); + application.setContactName(request.contactName().trim()); + application.setContactPhone(request.contactPhone().trim()); + application.setRemark(trim(request.remark())); + application.setStatus(PENDING_PROCESSING); + application.setItemCount(request.items().size()); + application.setTotalPackageCount(total); + application.setNotifiedPackageCount(notified); + application.setIdempotencyKey(request.idempotencyKey().trim()); + application.setRequestHash(requestHash); + application.setCreatedBy(trim(operatorId)); + application.setCreatedAt(now); + application.setUpdatedAt(now); + applications.insert(application); + + for (RelabelRequests.Item source : request.items()) insertItem(customerId, applicationId, source, now); + insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); + insertOrderHistory(customerId, order.getId(), null, order.getCurrentStatus(), + "Relabel application submitted", now); + return detail(customerId, relabelNo); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, RelabelRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { + throw badRequest("createdFrom must not be after createdTo"); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), RelabelApplication::getCustomerId, customerId) + .like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo()) + .like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo()) + .eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status()) + .eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId, query.overseasWarehouseId()) + .ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo()) + .apply(StringUtils.hasText(query.skuCode()), + "EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))", + query.skuCode()) + .orderByDesc(RelabelApplication::getCreatedAt); + Page page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper); + List records = page.getRecords().stream().map(value -> summary(value, itemList(value.getId()))).toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + @Transactional(readOnly = true) + public RelabelResponses.Detail detail(String customerId, String relabelNo) { + RelabelApplication application = require(customerId, relabelNo); + String recordCustomerId = application.getCustomerId(); + List detailItems = itemList(application.getId()); + List statusHistory = histories.selectList(new LambdaQueryWrapper() + .eq(RelabelStatusHistory::getCustomerId, recordCustomerId) + .eq(RelabelStatusHistory::getApplicationId, application.getId()) + .orderByAsc(RelabelStatusHistory::getCreatedAt)); + return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(), + detailItems.stream().map(this::item).toList(), statusHistory.stream().map(value -> + new RelabelResponses.History(value.getFromStatus(), value.getToStatus(), value.getOperatorId(), + value.getRemark(), value.getCreatedAt())).toList()); + } + + @Transactional + public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, + RelabelRequests.ChangeStatus request) { + RelabelApplication application = StringUtils.hasText(customerId) + ? applications.lockNo(customerId, relabelNo) + : applications.lockAnyByRelabelNo(relabelNo); + if (application == null) throw notFound("Relabel application not found"); + customerId = application.getCustomerId(); + String next = request.status().trim().toUpperCase(); + if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); + if (next.equals(application.getStatus())) return detail(customerId, relabelNo); + validateTransition(application.getStatus(), next); + String before = application.getStatus(); + LocalDateTime now = LocalDateTime.now(); + application.setStatus(next); + application.setUpdatedAt(now); + applications.updateById(application); + LastlegOrder order = orders.selectById(application.getLastlegOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing"); + String orderBefore = order.getCurrentStatus(); + String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore; + // A return-relabel tail order joins the normal inbound queue after labels are finished. + String orderStatus = switch (next) { + case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType()) + ? "PENDING_ASSIGNMENT" : "COMPLETED"; + case "REJECTED", "CANCELLED" -> "CANCELLED"; + default -> mainStatus; + }; + order.setCurrentStatus(orderStatus); + order.setSubOrderStatus("RELABEL:" + next); + order.setRelabelStatus(next); + order.setUpdatedAt(now); + orders.updateById(order); + insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now); + if (!Objects.equals(orderBefore, orderStatus)) { + insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now); + } + return detail(customerId, relabelNo); + } + + private void insertItem(String customerId, long applicationId, RelabelRequests.Item source, LocalDateTime now) { + RelabelItem item = new RelabelItem(); + item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId); + item.setSkuCode(source.skuCode().trim()); item.setProductImageFileId(trim(source.productImageFileId())); + item.setProductImageFileName(trim(source.productImageFileName())); item.setProductImageUrl(trim(source.productImageUrl())); + item.setProductNameZh(source.productNameZh().trim()); item.setTotalPackageCount(source.totalPackageCount()); + item.setNotifiedPackageCount(source.notifiedPackageCount()); item.setShipmentNo(source.shipmentNo().trim()); + item.setTrackingCode(trim(source.trackingCode())); item.setNewSkuCode(source.newSkuCode().trim()); + item.setLabelRequirement(trim(source.labelRequirement())); item.setRelabelFileId(trim(source.relabelFileId())); + item.setRelabelFileName(trim(source.relabelFileName())); item.setRelabelFileUrl(trim(source.relabelFileUrl())); + item.setCreatedAt(now); items.insert(item); + } + + private RelabelResponses.Summary summary(RelabelApplication value, List detailItems) { + LastlegOrder order = orders.selectById(value.getLastlegOrderId()); + String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(",")); + return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(), + order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(), value.getOverseasWarehouseId(), + value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(), + Objects.requireNonNullElse(value.getItemCount(), detailItems.size()), + Objects.requireNonNullElse(value.getTotalPackageCount(), 0), + Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes, value.getCreatedAt()); + } + + private RelabelResponses.Item item(RelabelItem value) { + return new RelabelResponses.Item(value.getId(), value.getSkuCode(), value.getProductImageFileId(), + value.getProductImageFileName(), value.getProductImageUrl(), value.getProductNameZh(), + value.getTotalPackageCount(), value.getNotifiedPackageCount(), value.getShipmentNo(), + value.getTrackingCode(), value.getNewSkuCode(), value.getLabelRequirement(), value.getRelabelFileId(), + value.getRelabelFileName(), value.getRelabelFileUrl()); + } + + private List itemList(Long applicationId) { + return items.selectList(new LambdaQueryWrapper().eq(RelabelItem::getApplicationId, applicationId) + .orderByAsc(RelabelItem::getId)); + } + private RelabelApplication require(String customerId, String relabelNo) { + RelabelApplication value = StringUtils.hasText(customerId) + ? applications.findNo(customerId, relabelNo) + : applications.findAnyByRelabelNo(relabelNo); + if (value == null) throw notFound("Relabel application not found"); + return value; + } + private void insertHistory(String customerId, Long id, String from, String to, String operator, + String remark, LocalDateTime now) { + RelabelStatusHistory value = new RelabelStatusHistory(); value.setId(IdWorker.getId()); value.setCustomerId(customerId); + value.setApplicationId(id); value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(trim(operator)); + value.setRemark(trim(remark)); value.setCreatedAt(now); histories.insert(value); + } + private void insertOrderHistory(String customerId, Long id, String from, String to, String remark, + LocalDateTime now) { + LastlegOrderStatusHistory value = new LastlegOrderStatusHistory(); value.setCustomerId(customerId); value.setOrderId(id); value.setFromStatus(from); + value.setToStatus(to); value.setEventId("RELABEL_APPLICATION"); value.setRemark(trim(remark)); value.setCreatedAt(now); + orderHistories.insert(value); + } + private static void validateTransition(String from, String to) { + boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to) + || "PROCESSING".equals(from) && Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(to); + if (!allowed) throw conflict("Relabel status cannot change from " + from + " to " + to); + } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} 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 new file mode 100644 index 000000000..53e110c8a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class UnloadCompletedOutboxRepository { + @Resource private LastlegEventOutboxMapper mapper; + + public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .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))) + .orderByAsc(LastlegEventOutbox::getCreatedAt).orderByAsc(LastlegEventOutbox::getId) + .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); + } + + public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id) + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .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))) + .set(LastlegEventOutbox::getStatus, "PROCESSING") + .set(LastlegEventOutbox::getLockedBy, worker) + .set(LastlegEventOutbox::getLockedAt, now)) == 1; + } + + public LastlegEventOutbox claimed(long id, String worker) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(LastlegEventOutbox::getId, id) + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .eq(LastlegEventOutbox::getStatus, "PROCESSING") + .eq(LastlegEventOutbox::getLockedBy, worker)); + } + + public void published(long id, String worker, LocalDateTime now) { + mapper.update(null, claimedBase(id, worker) + .set(LastlegEventOutbox::getStatus, "PUBLISHED") + .set(LastlegEventOutbox::getPublishedAt, now) + .set(LastlegEventOutbox::getNextRetryAt, null) + .set(LastlegEventOutbox::getLockedBy, null) + .set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, null)); + } + + public void retry(long id, String worker, LocalDateTime retryAt, String error) { + mapper.update(null, claimedBase(id, worker) + .setSql("retry_count = retry_count + 1") + .set(LastlegEventOutbox::getStatus, "PENDING") + .set(LastlegEventOutbox::getNextRetryAt, retryAt) + .set(LastlegEventOutbox::getLockedBy, null) + .set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } + + private LambdaUpdateWrapper claimedBase(long id, String worker) { + return new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id) + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .eq(LastlegEventOutbox::getStatus, "PROCESSING") + .eq(LastlegEventOutbox::getLockedBy, worker); + } + + private static String truncate(String value) { + String safe = value == null || value.isBlank() ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " "); + return safe.length() > 500 ? safe.substring(0, 500) : safe; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/package-info.java new file mode 100644 index 000000000..b3cd17d05 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/package-info.java @@ -0,0 +1,5 @@ +/** + * Tenant-scoped aggregate queries and multi-mapper persistence composition. + * / 租户范围聚合查询及多 Mapper 持久化组合。 + */ +package com.baiyue.lastleg.repository; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java new file mode 100644 index 000000000..ba4ef399b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.security; + +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.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + private final String expectedToken; + public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) { this.expectedToken=expectedToken; } + public void verify(String actualToken) { + if(expectedToken==null||expectedToken.isBlank()) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"内部事件接收令牌未配置"); + byte[] expected=expectedToken.getBytes(StandardCharsets.UTF_8); + byte[] actual=actualToken==null?new byte[0]:actualToken.getBytes(StandardCharsets.UTF_8); + if(!MessageDigest.isEqual(expected,actual)) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"内部服务令牌无效"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java new file mode 100644 index 000000000..5ac51600c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.security; + +/** Canonical Admin authorities for fourth-phase overseas APIs. */ +public final class OverseasPermissions { + public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list"; + public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit"; + public static final String LAST_MILE_ORDER_EXPORT = "platform:overseas-warehouse:last-mile-order:export"; + public static final String INBOUND_ORDER_LIST = "platform:overseas-warehouse:inbound-order:list"; + public static final String INBOUND_ORDER_CREATE = "platform:overseas-warehouse:inbound-order:create"; + public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit"; + public static final String INBOUND_ORDER_FBA_APPOINTMENT = + "platform:overseas-warehouse:inbound-order:fba-appointment"; + public static final String INBOUND_ORDER_SMART_DISPATCH = + "platform:overseas-warehouse:inbound-order:smart-dispatch"; + public static final String RELABEL_ORDER_LIST = "platform:overseas-warehouse:relabel-order:list"; + public static final String RELABEL_ORDER_CREATE = "platform:overseas-warehouse:relabel-order:create"; + public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status"; + public static final String INVENTORY_LIST = "platform:overseas-warehouse:inventory:list"; + public static final String INVENTORY_EXPORT = "platform:overseas-warehouse:inventory:export"; + public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit"; + public static final String INVENTORY_ADJUST = "platform:overseas-warehouse:inventory:adjust"; + public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list"; + public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit"; + public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review"; + public static final String TRUCK_LIST = "platform:overseas-business:truck:list"; + public static final String TRUCK_CREATE = "platform:overseas-business:truck:create"; + public static final String TRUCK_EDIT = "platform:overseas-business:truck:edit"; + public static final String TRUCK_STATUS = "platform:overseas-business:truck:status"; + public static final String LOOSEBOARD_LIST = "platform:overseas-business:looseboard:list"; + public static final String LOOSEBOARD_CREATE = "platform:overseas-business:looseboard:create"; + public static final String LOOSEBOARD_EDIT = "platform:overseas-business:looseboard:edit"; + public static final String LOOSEBOARD_STATUS = "platform:overseas-business:looseboard:status"; + public static final String QUOTE_LIST = "platform:overseas-master-data:quote:list"; + public static final String QUOTE_CREATE = "platform:overseas-master-data:quote:create"; + public static final String QUOTE_EDIT = "platform:overseas-master-data:quote:edit"; + public static final String QUOTE_STATUS = "platform:overseas-master-data:quote:status"; + public static final String QUOTE_EXPORT = "platform:overseas-master-data:quote:export"; + public static final String EXCHANGE_RATE_LIST = "platform:overseas-master-data:exchange-rate:list"; + public static final String EXCHANGE_RATE_CREATE = "platform:overseas-master-data:exchange-rate:create"; + public static final String EXCHANGE_RATE_EDIT = "platform:overseas-master-data:exchange-rate:edit"; + public static final String EXCHANGE_RATE_STATUS = "platform:overseas-master-data:exchange-rate:status"; + public static final String EXCHANGE_RATE_EXPORT = "platform:overseas-master-data:exchange-rate:export"; + public static final String VEHICLE_LIST = "platform:overseas-master-data:vehicle:list"; + public static final String VEHICLE_CREATE = "platform:overseas-master-data:vehicle:create"; + public static final String VEHICLE_EDIT = "platform:overseas-master-data:vehicle:edit"; + public static final String VEHICLE_STATUS = "platform:overseas-master-data:vehicle:status"; + public static final String VEHICLE_EXPORT = "platform:overseas-master-data:vehicle:export"; + + private OverseasPermissions() { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java new file mode 100644 index 000000000..6b3cb5093 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.security.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.Acknowledgement; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; +import com.baiyue.lastleg.security.service.LastlegSecurityProjectionService; +import com.baiyue.lastleg.security.service.LastlegSecurityValidationService; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; + +@Hidden +@RestController +@RequestMapping("/internal/lastleg/security/v1") +public class LastlegSecurityController { + private final InternalServiceTokenGuard tokenGuard; + private final LastlegSecurityProjectionService projections; + private final LastlegSecurityValidationService validations; + + public LastlegSecurityController(InternalServiceTokenGuard tokenGuard, + LastlegSecurityProjectionService projections, + LastlegSecurityValidationService validations) { + this.tokenGuard = tokenGuard; + this.projections = projections; + this.validations = validations; + } + + @PostMapping("/events") + public ApiResponse receive( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope envelope) { + tokenGuard.verify(token); + var result = projections.consume(envelope); + return ApiResponse.success(new Acknowledgement(envelope.eventId(), result.result(), Instant.now())); + } + + @PostMapping("/validate") + public ApiResponse validate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody ValidationRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(validations.validate(request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java new file mode 100644 index 000000000..cb301fd7f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.security.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; + +public final class LastlegSecurityDtos { + private LastlegSecurityDtos() { } + + public record Acknowledgement(String eventId, String result, Instant receivedAt) { } + + public record ValidationRequest(@NotNull @Min(1) Long userId, + @NotNull @Min(0) Long tenantId, + @NotNull @Min(0) Long permissionVersion) { } + + public record ValidationResponse(boolean valid, FailureReason failureReason) { + public static ValidationResponse accepted() { return new ValidationResponse(true, null); } + public static ValidationResponse rejected(FailureReason reason) { + return new ValidationResponse(false, reason); + } + } + + public enum FailureReason { + SECURITY_PROJECTION_MISSING, + SECURITY_PROJECTION_NOT_READY, + USER_DISABLED, + TENANT_DISABLED, + PERMISSION_VERSION_STALE + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java new file mode 100644 index 000000000..5e888487b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.security.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */ +@Component +@Slf4j +public class LastlegSecurityProjectionRedisMirror { + private final StringRedisTemplate redis; + private final ObjectMapper objectMapper; + @Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}") + private String keyPrefix; + + public LastlegSecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { + this.redis = redis; + this.objectMapper = objectMapper; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void mirror(LastlegSecurityProjectionService.ProjectionChanged event) { + var value = event.projection(); + try { + redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(), + objectMapper.writeValueAsString(value)); + } catch (Exception exception) { + log.warn("海外安全投影 Redis 镜像写入失败 aggregateType={}, aggregateId={}", + value.aggregateType(), value.aggregateId()); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java new file mode 100644 index 000000000..dcfe0ab4b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java @@ -0,0 +1,188 @@ +package com.baiyue.lastleg.security.service; + +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.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +@Service +public class LastlegSecurityProjectionService { + private static final List TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED); + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final ApplicationEventPublisher events; + + public LastlegSecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, + ApplicationEventPublisher events) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.events = events; + } + + @Transactional + public ConsumeResult consume(EventEnvelope envelope) { + envelope.requireValid(); + requireSecurityEnvelope(envelope); + String payloadHash = validatedStorageHash(envelope); + String payloadJson = envelope.payload().toString(); + try { + jdbc.update("INSERT INTO ll_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " + + "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(), + payloadHash, payloadJson); + } catch (DuplicateKeyException duplicate) { + InboxRow existing = jdbc.queryForObject( + "SELECT event_type,payload_hash,status FROM ll_security_event_inbox " + + "WHERE event_id=? FOR UPDATE", + (rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)), + envelope.eventId()); + if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType()) + || existing.payloadHash() == null + || !existing.payloadHash().equalsIgnoreCase(payloadHash)) { + throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突"); + } + if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null); + } + + ProjectionInput input = input(envelope, payloadHash); + ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId()); + SecurityProjectionMonotonicity.Decision decision = SecurityProjectionMonotonicity.decide( + current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(), + input.aggregateVersion(), input.payloadHash()); + String result = decision.name(); + if (decision == SecurityProjectionMonotonicity.Decision.APPLY) { + upsert(input); + result = "APPLIED"; + events.publishEvent(new ProjectionChanged(input)); + } + jdbc.update("UPDATE ll_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " + + "WHERE event_id=?", result, envelope.eventId()); + return new ConsumeResult(result, input); + } + + private void requireSecurityEnvelope(EventEnvelope envelope) { + if (!TYPES.contains(envelope.eventType()) || envelope.eventVersion() != IamSecurityEventsV1.VERSION + || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { + throw new BizException(ResultCode.BAD_REQUEST, "仅接受 CN→OVERSEAS IAM V1 安全事件"); + } + } + + static String validatedStorageHash(EventEnvelope envelope) { + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) { + throw new BizException(ResultCode.BAD_REQUEST, "payloadHash 与 IAM 安全事件负载不一致"); + } + // 表内只保存固定 64 位摘要;信封传输契约仍保留 sha256: 前缀。 + return envelope.payloadHash().substring("sha256:".length()).toLowerCase(Locale.ROOT); + } + + private ProjectionInput input(EventEnvelope envelope, String payloadHash) { + try { + return switch (envelope.eventType()) { + case EventTypes.IAM_USER_STATUS_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.UserStatusChanged.class); + requireIdentity(envelope, "USER", value.authUserId(), value.tenantId()); + requirePayload(value.status(), value.changedAt(), value.permissionVersion(), + envelope.aggregateVersion()); + requireStatus(value.status()); + yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(), + value.status(), value.permissionVersion(), envelope.aggregateVersion(), + payloadHash, envelope.eventId(), value.changedAt()); + } + case EventTypes.IAM_TENANT_STATUS_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.TenantStatusChanged.class); + requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId()); + requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), + envelope.aggregateVersion()); + requireStatus(value.status()); + yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(), + value.status(), null, envelope.aggregateVersion(), payloadHash, + envelope.eventId(), value.changedAt()); + } + case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.PermissionVersionChanged.class); + requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId()); + requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), + envelope.aggregateVersion()); + yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(), + null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash, + envelope.eventId(), value.changedAt()); + } + default -> throw new IllegalArgumentException("unsupported type"); + }; + } catch (BizException exception) { + throw exception; + } catch (Exception exception) { + throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全事件 payload 无效"); + } + } + + private static void requireIdentity(EventEnvelope envelope, String aggregateType, + long aggregateId, long tenantId) { + if (!aggregateType.equals(envelope.aggregateType()) + || !String.valueOf(aggregateId).equals(envelope.aggregateId()) + || !String.valueOf(tenantId).equals(envelope.tenantId())) { + throw new BizException(ResultCode.CONFLICT, "IAM 安全事件信封与 payload 身份不一致"); + } + } + + private static void requirePayload(String requiredText, Instant changedAt, + long payloadVersion, long aggregateVersion) { + if (requiredText == null || requiredText.isBlank() || changedAt == null + || payloadVersion != aggregateVersion) { + throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效"); + } + } + + private static void requireStatus(String status) { + if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) { + throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全状态仅允许 ACTIVE 或 DISABLED"); + } + } + + private ProjectionRow findForUpdate(String type, String id) { + List rows = jdbc.query( + "SELECT aggregate_version,payload_hash FROM ll_security_projection " + + "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE", + (rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id); + return rows.isEmpty() ? null : rows.get(0); + } + + private void upsert(ProjectionInput value) { + jdbc.update("INSERT INTO ll_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," + + "permission_version,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) " + + "VALUES (?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)," + + "security_status=VALUES(security_status),permission_version=VALUES(permission_version)," + + "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash)," + + "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)", + value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), + value.permissionVersion(), value.aggregateVersion(), value.payloadHash(), value.eventId(), + value.changedAt()); + } + + private record InboxRow(String eventType, String payloadHash, String status) { } + private record ProjectionRow(long aggregateVersion, String payloadHash) { } + public record ConsumeResult(String result, ProjectionInput projection) { } + public record ProjectionChanged(ProjectionInput projection) { } + public record ProjectionInput(String aggregateType, String aggregateId, long tenantId, String status, + Long permissionVersion, long aggregateVersion, String payloadHash, + String eventId, Instant changedAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java new file mode 100644 index 000000000..b23605379 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class LastlegSecurityValidationService { + private final JdbcTemplate jdbc; + + public LastlegSecurityValidationService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public ValidationResponse validate(ValidationRequest request) { + String userStatus = status("USER", String.valueOf(request.userId()), request.tenantId()); + if (userStatus == null) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + if (!"ACTIVE".equals(userStatus)) { + return ValidationResponse.rejected(FailureReason.USER_DISABLED); + } + if (request.tenantId() > 0) { + String tenantStatus = status("TENANT", String.valueOf(request.tenantId()), request.tenantId()); + if (tenantStatus == null) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + if (!"ACTIVE".equals(tenantStatus)) { + return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); + } + } + List projectedVersions = jdbc.query( + "SELECT MAX(permission_version) FROM ll_security_projection " + + "WHERE aggregate_id=? AND tenant_id=? " + + "AND aggregate_type IN ('USER','AUTHORIZATION') HAVING COUNT(permission_version)>0", + (rs, row) -> rs.getLong(1), String.valueOf(request.userId()), request.tenantId()); + if (projectedVersions.isEmpty()) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + long projectedVersion = projectedVersions.get(0); + if (request.permissionVersion() < projectedVersion) { + return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); + } + if (request.permissionVersion() > projectedVersion) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_NOT_READY); + } + return ValidationResponse.accepted(); + } + + private String status(String type, String id, long tenantId) { + List values = jdbc.query("SELECT security_status FROM ll_security_projection " + + "WHERE aggregate_type=? AND aggregate_id=? AND tenant_id=?", + (rs, row) -> rs.getString(1), type, id, tenantId); + return values.isEmpty() ? null : values.get(0); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java new file mode 100644 index 000000000..4085bc4de --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Objects; + +final class SecurityProjectionMonotonicity { + private SecurityProjectionMonotonicity() { } + + static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) { + if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY; + if (incomingVersion < currentVersion) return Decision.STALE; + if (Objects.equals(currentHash, incomingHash)) return Decision.DUPLICATE_VERSION; + throw new BizException(ResultCode.CONFLICT, "相同聚合版本的安全事件 payloadHash 冲突"); + } + + enum Decision { APPLY, STALE, DUPLICATE_VERSION } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java new file mode 100644 index 000000000..86b89766f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import org.springframework.stereotype.Component; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +@Component +public class LastlegOrderNoGenerator { + public String next() { + return "LMO" + IdWorker.getIdStr(); + } + + /** The physical container number is the tail-leg business order grouping boundary. */ + public String forContainerNo(String containerNo) { + if (containerNo == null || containerNo.isBlank()) { + throw new IllegalArgumentException("containerNo is required"); + } + String normalized = containerNo.trim(); + if (normalized.matches("[0-9]{1,29}")) { + return "LMO" + normalized; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(normalized.getBytes(StandardCharsets.UTF_8)); + String digits = new BigInteger(1, digest).toString(); + return "LMO" + digits.substring(0, Math.min(29, digits.length())); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java new file mode 100644 index 000000000..8b88d3694 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -0,0 +1,199 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.model.LastlegOrderType; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Service +public class LastlegOrderService { + public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT"; + public static final String DESTINATION_ASSIGNED = "DESTINATION_ASSIGNED"; + public static final String INBOUND_PLANNED = "INBOUND_PLANNED"; + + @Resource private LastlegOrderMapper orderMapper; + @Resource private LastlegOrderDetailMapper detailMapper; + @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private InboundPlanCartonMapper cartonMapper; + + public PageResult page(String customerId, LastlegOrderRequests.Query query) { + Page page = orderMapper.selectPage( + new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt)); + return new PageResult<>(page.getRecords().stream().map(this::summary).toList(), + page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public LastlegOrderDetailResponse detail(String customerId, long orderId) { + LastlegOrder order = findOwned(customerId, orderId); + List cargo = detailMapper.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); + List history = historyMapper.selectList( + new LambdaQueryWrapper() + .eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderStatusHistory::getOrderId, orderId) + .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); + return new LastlegOrderDetailResponse(summary(order), cargo, history.stream() + .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), + value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList()); + } + + @Transactional + public int transferDestination(String customerId, LastlegOrderRequests.TransferDestination request) { + List ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required"); + } + List orders = orderMapper.selectList(new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) + .in(LastlegOrder::getId, ids).last("FOR UPDATE")); + if (orders.size() != ids.size()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found"); + } + for (LastlegOrder order : orders) { + ensureTransferable(order); + } + LocalDateTime now = LocalDateTime.now(); + for (LastlegOrder order : orders) { + String fromStatus = order.getCurrentStatus(); + order.setTransferWarehouseId(request.warehouseId()); + order.setTransferWarehouseCode(request.warehouseCode()); + order.setTransferWarehouseName(request.warehouseName()); + order.setCurrentStatus(DESTINATION_ASSIGNED); + order.setUpdatedAt(now); + orderMapper.updateById(order); + + LastlegOrderDetail detailUpdate = new LastlegOrderDetail(); + detailUpdate.setDestinationWarehouseId(request.warehouseId()); + detailUpdate.setDestinationWarehouseCode(request.warehouseCode()); + detailUpdate.setDestinationWarehouseName(request.warehouseName()); + detailMapper.update(detailUpdate, new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderDetail::getOrderId, order.getId())); + + InboundPlanCarton cartonUpdate = new InboundPlanCarton(); + cartonUpdate.setDestinationWarehouseId(request.warehouseId()); + cartonUpdate.setDestinationWarehouseCode(request.warehouseCode()); + cartonUpdate.setDestinationWarehouseName(request.warehouseName()); + cartonMapper.update(cartonUpdate, new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, order.getCustomerId()) + .eq(InboundPlanCarton::getOrderId, order.getId())); + + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setCustomerId(order.getCustomerId()); + history.setOrderId(order.getId()); + history.setFromStatus(fromStatus); + history.setToStatus(DESTINATION_ASSIGNED); + history.setRemark(StringUtils.hasText(request.remark()) + ? request.remark() : "Destination warehouse assigned: " + request.warehouseCode()); + history.setCreatedAt(now); + historyMapper.insert(history); + } + return orders.size(); + } + + public byte[] exportCsv(String customerId, LastlegOrderRequests.Query query) { + List orders = orderMapper.selectList(filter(customerId, query) + .orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000")); + List lines = new ArrayList<>(); + lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)"); + for (LastlegOrder order : orders) { + lines.add(String.join(",", List.of( + csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())), + csv(order.getInboundCode()), + csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), + csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), + csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()), + csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), + csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), + csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()), + csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), + csv(order.getCustomerRemark()), csv(order.getWarehouseRemark())))); + } + return ("\uFEFF" + String.join("\r\n", lines)).getBytes(StandardCharsets.UTF_8); + } + + private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); + } + String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status(); + return new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo()) + .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) + .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) + .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) + .like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo()) + .eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus) + .and(StringUtils.hasText(query.warehouse()), wrapper -> wrapper + .like(LastlegOrder::getOverseasWarehouseId, query.warehouse()) + .or().like(LastlegOrder::getOverseasWarehouseName, query.warehouse()) + .or().like(LastlegOrder::getTransferWarehouseCode, query.warehouse()) + .or().like(LastlegOrder::getTransferWarehouseName, query.warehouse())) + .eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode, + query.transferWarehouseCode()) + .eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal()) + .eq(query.detained() != null, LastlegOrder::getDetained, query.detained()) + .ge(query.createdFrom() != null, LastlegOrder::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, LastlegOrder::getCreatedAt, query.createdTo()); + } + + private LastlegOrder findOwned(String customerId, long orderId) { + LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) + .eq(LastlegOrder::getId, orderId)); + if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); + return order; + } + + private void ensureTransferable(LastlegOrder order) { + if (StringUtils.hasText(order.getInboundOrderNo()) || INBOUND_PLANNED.equals(order.getCurrentStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Destination warehouse cannot be changed after inbound plan creation: " + order.getId()); + } + } + + private LastlegOrderSummary summary(LastlegOrder o) { + return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), + LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), + o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), + o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), + o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), + o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), + o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), + o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(), + o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), + o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); + } + + private static String csv(Object value) { + if (value == null) return ""; + String text = String.valueOf(value); + if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text; + return '"' + text.replace("\"", "\"\"") + '"'; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java new file mode 100644 index 000000000..0bf133005 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -0,0 +1,387 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.common.event.*; +import com.baiyue.lastleg.entity.*; +import com.baiyue.lastleg.mapper.*; +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import com.fasterxml.jackson.databind.JsonNode; + +@Service +public class LoadingCompletedConsumer { + @Resource private LastlegEventInboxMapper inboxMapper; + @Resource private LastlegOrderMapper orderMapper; + @Resource private LastlegInboundOrderMapper inboundOrderMapper; + @Resource private LastlegOrderDetailMapper detailMapper; + @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private InboundPlanCartonMapper cartonMapper; + @Resource private ObjectMapper objectMapper; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private DropshipOrderService dropshipOrders; + @Resource private InventoryCommandService inventory; + @Resource private ContainerShippingGateMapper shippingGates; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public int consume(EventEnvelope envelope) { + envelope.requireValid(); + if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) + || (envelope.eventVersion() != 1 && envelope.eventVersion() != 2)) + throw new IllegalArgumentException("不支持的尾程交接事件"); + if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) + throw new IllegalArgumentException("尾程交接事件区域无效"); + if (!"LOADING_PLAN".equals(envelope.aggregateType())) + throw new IllegalArgumentException("尾程交接事件聚合类型无效"); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致"); + FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope); + if(payload.containerNo()==null||payload.containerNo().isBlank()) + throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); + if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); + if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload); + LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); + if(existing!=null) { + if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); + return countOrders(envelope,payload.containerNo()); + } + + LastlegEventInbox inbox=new LastlegEventInbox(); + inbox.setEventId(envelope.eventId()); inbox.setCustomerId(envelope.tenantId()); inbox.setEventType(envelope.eventType()); + inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash()); + inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now()); + try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) { + LastlegEventInbox raced = inboxMapper.selectById(envelope.eventId()); + if (raced == null || raced.getPayloadHash() == null + || !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("事件摘要冲突", exception); + } + return countOrders(envelope,payload.containerNo()); + } + + // The loading-plan and container-shipping events are independent aggregate streams. + // Seeding the shipping version from the loading-plan version makes a valid first node + // (aggregateVersion=0) look stale whenever the loading plan has a higher version. + shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "", + payload.containerNo(), 0, envelope.handoverVersion()); + + String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); + JsonNode rawOrders = envelope.payload().path("orders"); + for (int index = 0; index < payload.orders().size(); index++) { + var source = payload.orders().get(index); + JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode(); + createOrder(envelope, payload, source, orderNo, rawSource); + } + inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox); + return payload.orders().size(); + } + + private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) { + try { + ObjectMapper compatibleMapper = objectMapper.copy() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + // V2 only adds immutable item remarks; the current tail-leg snapshot model + // intentionally ignores those fields until its schema exposes them. + return compatibleMapper.treeToValue(envelope.payload(), FirstLegLoadingPlanCompletedV1.class); + } + catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); } + } + + private int countOrders(EventEnvelope envelope,String containerNo) { + return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo)); + } + + private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) { + for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) { + Set orderCartonMarks = new HashSet<>(); + if ("ONE_CLICK_DROPSHIP".equals(order.orderType())) { + for (FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet : order.pallets()) { + for (FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot sku : pallet.skuPackages()) { + requireSkuCartonMapping(sku.skuCode(), sku.packageCount(), sku.cartonMarks(), orderCartonMarks); + } + } + continue; + } + String destinationType = destinationType(payload, order); + for (FirstLegLoadingPlanCompletedV1.Item item : order.items()) { + if ("OVERSEAS".equals(destinationType)) { + requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks); + } else { + validateOptionalFbaCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks); + } + } + } + } + + private void validateOptionalFbaCartonMapping(String skuCode, int loadedPackages, List cartonMarks, + Set orderCartonMarks) { + List marks = cartonMarks == null ? List.of() : cartonMarks; + if (skuCode == null || skuCode.isBlank()) { + throw new IllegalArgumentException("V2 尾程交接事件缺少 SKU"); + } + if (marks.isEmpty()) return; + requireSkuCartonMapping(skuCode, loadedPackages, marks, orderCartonMarks); + } + + private void requireSkuCartonMapping(String skuCode, int loadedPackages, List cartonMarks, + Set orderCartonMarks) { + if (skuCode == null || skuCode.isBlank()) { + throw new IllegalArgumentException("V2 尾程交接事件的箱唛映射缺少 SKU"); + } + List marks = cartonMarks == null ? List.of() : cartonMarks; + if (marks.size() != loadedPackages) { + throw new IllegalArgumentException("V2 尾程交接事件的 SKU 箱唛数量必须等于实装箱数: " + skuCode); + } + for (String mark : marks) { + if (mark == null || mark.isBlank() || !orderCartonMarks.add(mark)) { + throw new IllegalArgumentException("V2 尾程交接事件包含空值或重复箱唛: " + skuCode); + } + } + } + + private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload, + FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource) { + String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource)); + LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId()); + if(existing!=null) { + if(!Objects.equals(existing.getSourcePayloadHash(),orderHash)) throw new IllegalStateException("来源入仓单快照冲突: "+source.sourceInboundId()); + return; + } + LastlegOrder order=new LastlegOrder(); + String orderType = source.orderType(); + String destinationType = destinationType(payload, source); + String customerId = source.customerId() == null || source.customerId().isBlank() + ? event.tenantId() : source.customerId(); + if (!event.tenantId().equals(customerId)) { + throw new IllegalArgumentException("Order customerId does not match the trusted event customer"); + } + List dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType) + ? pallets(source, rawSource) : List.of(); + if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) { + throw new IllegalArgumentException("One-click dropship event has no pallet snapshot"); + } + order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType); + order.setDestinationType(destinationType); + order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); + order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); + order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); + order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName()); + order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null); + order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo()); + order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false); + order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size()); + order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm()); + order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); + order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); + order.setSourceLoadingPlanId(payload.loadingPlanId()); + order.setSourceContainerBookingId(payload.containerBookingId()); + order.setSourceLoadingPlanVersion(event.aggregateVersion()); + order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); + order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); + try { + orderMapper.insert(order); + } catch (DuplicateKeyException exception) { + LastlegOrder raced = orderMapper.findSource(event.tenantId(), event.sourceRegion().name(), + source.sourceInboundId()); + if (raced != null && Objects.equals(raced.getSourcePayloadHash(), orderHash)) return; + throw exception; + } + createInboundOrderSnapshot(event, payload, source, order, orderHash); + + for(var item:source.items()) { + LastlegOrderDetail detail=new LastlegOrderDetail(); + detail.setCustomerId(customerId); + detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode()); + detail.setProductNameZh(item.productNameZh()); detail.setProductNameEn(item.productNameEn()); detail.setPackageCount(item.packageCount()); + detail.setProductQuantity(item.productQuantity()); detail.setPalletCount(item.palletCount()); detail.setGrossWeightKg(item.grossWeightKg()); + detail.setNetWeightKg(item.netWeightKg()); detail.setDimensionalWeightKg(item.dimensionalWeightKg()); detail.setVolumeCbm(item.volumeCbm()); + detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode()); + detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo()); + detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail); + createCartonLabels(customerId, order.getId(), destinationType, item); + } + LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId); + history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); + history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history); + if ("FIRST_LEG".equals(orderType)) { + // The cross-region handover freezes the source inbound code only. An inbound plan is + // created later by an explicit platform conversion, which replaces this display snapshot. + registerInTransit(event, payload, source, order, source.inboundCode()); + } + if ("ONE_CLICK_DROPSHIP".equals(orderType)) { + long warehouseId; + try { warehouseId = Long.parseLong(payload.warehouseId()); } + catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); } + dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId, + event.eventId() + ":DROPSHIP", dropshipPallets)); + } + } + + private void createInboundOrderSnapshot(EventEnvelope event, FirstLegLoadingPlanCompletedV1 payload, + FirstLegLoadingPlanCompletedV1.Order source, LastlegOrder order, + String orderHash) { + LastlegInboundOrder snapshot = new LastlegInboundOrder(); + snapshot.setId(order.getId()); + snapshot.setCustomerId(order.getCustomerId()); snapshot.setSourceRegion(event.sourceRegion().name()); + snapshot.setSourceInboundId(source.sourceInboundId()); snapshot.setRootInboundId(source.rootInboundId()); + snapshot.setSourceOrderId(source.sourceOrderId()); snapshot.setSourceOrderNo(source.sourceOrderNo()); + snapshot.setRootOrderId(source.rootOrderId()); snapshot.setInboundCode(source.inboundCode()); + snapshot.setDestinationWarehouseId(source.destinationWarehouseId()); + snapshot.setDestinationWarehouseCode(source.destinationWarehouseCode()); + snapshot.setDestinationWarehouseName(source.destinationWarehouseName()); + snapshot.setDestinationType(order.getDestinationType()); + snapshot.setPackageCount(source.packageCount()); snapshot.setProductQuantity(source.productQuantity()); + snapshot.setPalletCount(source.palletCount()); snapshot.setGrossWeightKg(source.grossWeightKg()); + snapshot.setNetWeightKg(source.netWeightKg()); snapshot.setVolumeCbm(source.volumeCbm()); + snapshot.setSourceEventId(event.eventId()); snapshot.setSourceLoadingPlanId(payload.loadingPlanId()); + snapshot.setSourceLoadingPlanVersion(event.aggregateVersion()); snapshot.setHandoverVersion(event.handoverVersion()); + snapshot.setPayloadHash(orderHash); snapshot.setStatus("READY"); snapshot.setExceptionFlag("NONE"); + snapshot.setCreatedAt(order.getCreatedAt()); snapshot.setUpdatedAt(order.getUpdatedAt()); + inboundOrderMapper.insert(snapshot); + } + + private void registerInTransit(EventEnvelope event, FirstLegLoadingPlanCompletedV1 payload, + FirstLegLoadingPlanCompletedV1.Order source, LastlegOrder order, + String inboundOrderNo) { + for (int index = 0; index < source.items().size(); index++) { + FirstLegLoadingPlanCompletedV1.Item item = source.items().get(index); + if (item.packageCount() <= 0) continue; + inventory.registerInTransit(order.getCustomerId(), "SYSTEM", + new InventoryCommandRequests.InTransit(order.getCustomerName(), payload.warehouseId(), null, + payload.warehouseName(), item.destinationWarehouseId(), item.destinationWarehouseCode(), + item.destinationWarehouseName(), order.getId(), inboundOrderNo, source.inboundCode(), + item.skuCode(), item.productNameZh(), item.productNameEn(), item.packageCount(), + order.getOrderNo(), event.eventId(), + "TAIL_IN_TRANSIT:" + event.eventId() + ":" + order.getId() + ":" + index, + "Tail-leg order created from completed loading plan")); + } + } + + private void createCartonLabels(String customerId, Long orderId, String destinationType, + FirstLegLoadingPlanCompletedV1.Item item) { + if (!"OVERSEAS".equals(destinationType)) return; + List marks = item.cartonMarks() == null ? List.of() : item.cartonMarks(); + if (marks.isEmpty()) return; + if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) { + throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛"); + } + for (String mark : marks) { + InboundPlanCarton carton = new InboundPlanCarton(); + carton.setId(IdWorker.getId()); carton.setCustomerId(customerId); carton.setOrderId(orderId); + carton.setSourceInboundDetailId(item.sourceInboundDetailId()); carton.setSkuCode(item.skuCode()); + carton.setCartonMark(mark); carton.setDestinationWarehouseId(item.destinationWarehouseId()); + carton.setDestinationWarehouseCode(item.destinationWarehouseCode()); + carton.setDestinationWarehouseName(item.destinationWarehouseName()); carton.setStatus("UNASSIGNED"); + carton.setVersion(0); carton.setUpdatedAt(LocalDateTime.now()); + try { cartonMapper.insert(carton); } catch (DuplicateKeyException ignored) { } + } + } + + private String destinationType(FirstLegLoadingPlanCompletedV1 payload, + FirstLegLoadingPlanCompletedV1.Order order) { + String explicit = normalizeDestinationType(order.destinationType()); + if (explicit != null) return explicit; + + boolean fbaEvidence = order.items().stream().anyMatch(item -> hasText(item.fbaTrackingNo()) + || hasText(item.shipmentNo()) || startsWithFba(item.destinationWarehouseName())); + boolean overseasWarehouseIdentity = hasText(payload.warehouseId()) + && Objects.equals(payload.warehouseId(), order.destinationWarehouseId()); + if (fbaEvidence && overseasWarehouseIdentity) { + throw new IllegalArgumentException("历史尾程事件目的类型证据冲突: " + order.sourceInboundId()); + } + if (fbaEvidence) return "FBA"; + if (overseasWarehouseIdentity) return "OVERSEAS"; + throw new IllegalArgumentException("历史尾程事件缺少 destinationType 且无法安全推断: " + order.sourceInboundId()); + } + + private String normalizeDestinationType(String value) { + if (!hasText(value)) return null; + String normalized = value.trim().toUpperCase(java.util.Locale.ROOT); + if (!"FBA".equals(normalized) && !"OVERSEAS".equals(normalized)) { + throw new IllegalArgumentException("尾程事件 destinationType 必须为 FBA 或 OVERSEAS"); + } + return normalized; + } + + private boolean startsWithFba(String value) { + return hasText(value) && value.trim().toUpperCase(java.util.Locale.ROOT).startsWith("FBA"); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private List pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { + if (source.pallets() != null && !source.pallets().isEmpty()) { + return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(), + pallet.skuPackages().stream().map(sku -> new DropshipRequests.SkuPackages( + sku.skuCode(), sku.productName(), sku.packageCount(), sku.productQuantity(), + sku.cartonMarks())).toList())).toList(); + } + return legacyPallets(rawSource); + } + + private List legacyPallets(JsonNode source) { + List result = new ArrayList<>(); + JsonNode nodes = source.path("pallets"); + if (nodes.isArray()) { + for (JsonNode pallet : nodes) { + String palletNo = pallet.path("palletNo").asText(pallet.path("sourcePalletNo").asText(null)); + List skuRows = skuPackages(pallet); + if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) { + result.add(new DropshipRequests.Pallet(palletNo, skuRows)); + } + } + return result; + } + String palletNo = source.path("palletNo").asText(source.path("sourcePalletNo").asText(null)); + List skuRows = skuPackages(source); + if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) { + result.add(new DropshipRequests.Pallet(palletNo, skuRows)); + } + return result; + } + + private List skuPackages(JsonNode source) { + List result = new ArrayList<>(); + JsonNode nodes = source.path("skuPackages"); + if (!nodes.isArray()) nodes = source.path("items"); + if (!nodes.isArray()) return result; + for (JsonNode item : nodes) { + String sku = item.path("skuCode").asText(null); + long packageCount = item.path("packageCount").asLong(0); + long productQuantity = item.path("productQuantity").asLong(0); + JsonNode markNodes = item.path("cartonMarks"); + if (!markNodes.isArray()) markNodes = item.path("boxMarks"); + if (!markNodes.isArray()) markNodes = item.path("cartonNos"); + List marks = new ArrayList<>(); + if (markNodes.isArray()) { + for (JsonNode mark : markNodes) if (!mark.asText().isBlank()) marks.add(mark.asText()); + } + if (sku != null && !sku.isBlank() && packageCount > 0 && productQuantity > 0 && !marks.isEmpty()) { + String productName = item.path("productName").asText(item.path("productNameZh").asText(null)); + result.add(new DropshipRequests.SkuPackages(sku, productName, packageCount, productQuantity, marks)); + } + } + return result; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java new file mode 100644 index 000000000..01f4a18ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Business orchestration and transaction boundaries for last-leg delivery. + * / 尾程配送业务编排与事务边界。 + */ +package com.baiyue.lastleg.service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java new file mode 100644 index 000000000..509032dff --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.shipping; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +/** Registers the shipping gate persistence mapper used during event and receiving workflows. */ +@Configuration +@MapperScan(basePackages = "com.baiyue.lastleg.shipping.mapper") +public class ShippingMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java new file mode 100644 index 000000000..26b248db4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.shipping.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; + +@Data +@TableName("ll_container_shipping_gate") +public class ContainerShippingGate { + @TableId(type = IdType.INPUT) + private String containerBookingId; + private String customerId; + private String oceanBookingId; + private String containerNo; + private String status; + private String standardNode; + private Integer nodeSequence; + private LocalDateTime observedAt; + private LocalDateTime actualAt; + private String locationCode; + private String locationName; + private String providerCode; + private String providerEventId; + private String providerNodeCode; + private String sourceReference; + private String rawPayloadHash; + private Long aggregateVersion; + private Long handoverVersion; + private String lastEventId; + private String payloadHash; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java new file mode 100644 index 000000000..c505dca92 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.shipping.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ContainerShippingGateMapper extends BaseMapper { + @Select("SELECT * FROM ll_container_shipping_gate WHERE container_booking_id=#{containerBookingId} FOR UPDATE") + ContainerShippingGate lock(@Param("containerBookingId") String containerBookingId); + + @Insert("INSERT IGNORE INTO ll_container_shipping_gate(container_booking_id,customer_id,ocean_booking_id," + + "container_no,status,node_sequence,aggregate_version,handover_version,created_at,updated_at) VALUES(" + + "#{containerBookingId},#{customerId},#{oceanBookingId},#{containerNo},'AWAITING_PICKUP_READY',0," + + "#{aggregateVersion},#{handoverVersion},NOW(3),NOW(3))") + int initialize(@Param("containerBookingId") String containerBookingId, @Param("customerId") String customerId, + @Param("oceanBookingId") String oceanBookingId, @Param("containerNo") String containerNo, + @Param("aggregateVersion") long aggregateVersion, @Param("handoverVersion") long handoverVersion); + + @Select("SELECT CASE WHEN COUNT(DISTINCT o.source_container_booking_id)=1 " + + "AND MIN(CASE WHEN g.status='RECEIVING_AVAILABLE' THEN 1 ELSE 0 END)=1 THEN 1 ELSE 0 END " + + "FROM ll_order o LEFT JOIN ll_container_shipping_gate g " + + "ON g.container_booking_id=o.source_container_booking_id AND g.customer_id=o.customer_id " + + "WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo}") + int receivingAvailable(@Param("customerId") String customerId, @Param("containerNo") String containerNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java new file mode 100644 index 000000000..b641c1e46 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.shipping.service; + +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.FirstLegContainerShippingNodeRecordedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.entity.LastlegEventInbox; +import com.baiyue.lastleg.mapper.LastlegEventInboxMapper; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +@Service +public class ContainerShippingNodeConsumer { + public static final int PICKUP_READY_SEQUENCE = 80; + @Resource private LastlegEventInboxMapper inboxes; + @Resource private ContainerShippingGateMapper gates; + @Resource private ObjectMapper objectMapper; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public int consume(EventEnvelope envelope) { + validate(envelope); + FirstLegContainerShippingNodeRecordedV1 payload = payload(envelope); + validateEnvelopePayload(envelope, payload); + LastlegEventInbox existing = inboxes.selectById(envelope.eventId()); + if (existing != null) { + if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("船节点事件摘要冲突"); + } + return 1; + } + LastlegEventInbox inbox = inbox(envelope); + try { inboxes.insert(inbox); } + catch (DuplicateKeyException raced) { + LastlegEventInbox concurrent = inboxes.selectById(envelope.eventId()); + if (concurrent == null || !concurrent.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("船节点事件摘要冲突", raced); + } + return 1; + } + + gates.initialize(payload.containerBookingId(), payload.customerId(), payload.oceanBookingId(), + payload.containerNo(), 0, 1); + ContainerShippingGate gate = gates.lock(payload.containerBookingId()); + if (gate == null || !payload.customerId().equals(gate.getCustomerId()) + || !payload.containerNo().equals(gate.getContainerNo())) { + throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致"); + } + apply(gate, envelope, payload); + gates.updateById(gate); + inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxes.updateById(inbox); + return 1; + } + + private void apply(ContainerShippingGate gate, EventEnvelope envelope, + FirstLegContainerShippingNodeRecordedV1 payload) { + long currentVersion = gate.getAggregateVersion() == null ? 0 : gate.getAggregateVersion(); + int currentSequence = gate.getNodeSequence() == null ? 0 : gate.getNodeSequence(); + LocalDateTime actualAt = LocalDateTime.ofInstant(payload.actualAt(), ZoneOffset.UTC); + boolean newerVersion = envelope.aggregateVersion() >= currentVersion; + boolean newerNode = payload.nodeSequence() > currentSequence; + boolean laterSameNode = payload.nodeSequence() == currentSequence + && (gate.getActualAt() == null || actualAt.isAfter(gate.getActualAt())); + boolean accepted = newerVersion && (newerNode || laterSameNode); + if (accepted) { + gate.setOceanBookingId(payload.oceanBookingId()); + gate.setStandardNode(payload.standardNode()); gate.setNodeSequence(payload.nodeSequence()); + gate.setObservedAt(LocalDateTime.ofInstant(payload.observedAt(), ZoneOffset.UTC)); gate.setActualAt(actualAt); + gate.setLocationCode(payload.locationCode()); gate.setLocationName(payload.locationName()); + gate.setProviderCode(payload.providerCode()); gate.setProviderEventId(payload.providerEventId()); + gate.setProviderNodeCode(payload.providerNodeCode()); gate.setSourceReference(payload.sourceReference()); + gate.setRawPayloadHash(payload.rawPayloadHash()); gate.setLastEventId(envelope.eventId()); + gate.setPayloadHash(envelope.payloadHash()); + } + if (accepted && payload.nodeSequence() >= PICKUP_READY_SEQUENCE) { + gate.setStatus("RECEIVING_AVAILABLE"); + } + gate.setAggregateVersion(Math.max(currentVersion, envelope.aggregateVersion())); + gate.setHandoverVersion(Math.max(gate.getHandoverVersion() == null ? 0 : gate.getHandoverVersion(), + envelope.handoverVersion())); + gate.setUpdatedAt(LocalDateTime.now()); + } + + private void validate(EventEnvelope envelope) { + envelope.requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) { + throw new IllegalArgumentException("payloadHash 与船节点事件负载不一致"); + } + if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType()) + || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN + || envelope.targetRegion() != RegionCode.OVERSEAS + || !"CONTAINER_BOOKING".equals(envelope.aggregateType()) + || envelope.handoverVersion() == null + || envelope.handoverVersion() != envelope.aggregateVersion() + 1) { + throw new IllegalArgumentException("船节点交接信封无效"); + } + } + + private void validateEnvelopePayload(EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) { + if (!envelope.tenantId().equals(payload.customerId()) + || !envelope.aggregateId().equals(payload.containerBookingId())) { + throw new IllegalArgumentException("船节点交接客户或聚合标识不一致"); + } + } + + private FirstLegContainerShippingNodeRecordedV1 payload(EventEnvelope envelope) { + try { return objectMapper.treeToValue(envelope.payload(), FirstLegContainerShippingNodeRecordedV1.class); } + catch (Exception exception) { throw new IllegalArgumentException("船节点交接负载无效", exception); } + } + + private LastlegEventInbox inbox(EventEnvelope envelope) { + LastlegEventInbox value = new LastlegEventInbox(); value.setEventId(envelope.eventId()); + value.setCustomerId(envelope.tenantId()); value.setEventType(envelope.eventType()); + value.setEventVersion(envelope.eventVersion()); value.setPayloadHash(envelope.payloadHash()); + value.setPayloadJson(envelope.payload().toString()); value.setStatus("RECEIVED"); + value.setReceivedAt(LocalDateTime.now()); return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/TransferMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/TransferMapperConfiguration.java new file mode 100644 index 000000000..17bc09bb8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/TransferMapperConfiguration.java @@ -0,0 +1,4 @@ +package com.baiyue.lastleg.transfer; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; +@Configuration @MapperScan("com.baiyue.lastleg.transfer.mapper") public class TransferMapperConfiguration {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java new file mode 100644 index 000000000..811d58482 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.transfer.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; +import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers") +public class MerchantWarehouseTransferController { + @Resource private WarehouseTransferService service; + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody WarehouseTransferRequests.Create request) { + return ApiResponse.success(service.create(customer(user), operator(user), request)); + } + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid WarehouseTransferRequests.Query query) { + return ApiResponse.success(service.page(customer(user), query)); + } + + @GetMapping("/{transferNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo) { + return ApiResponse.success(service.detail(customer(user), transferNo)); + } + + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + } + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java new file mode 100644 index 000000000..89db8fb92 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java @@ -0,0 +1,100 @@ +package com.baiyue.lastleg.transfer.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; +import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/warehouse-transfers") +public class WarehouseTransferController { + public static final String MANAGE_PERMISSION = "lastleg:warehouse-transfer:manage"; + @Resource private WarehouseTransferService service; + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid @RequestBody WarehouseTransferRequests.Create request) { + return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); + } + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) { + return ApiResponse.success(service.page(scope(user, customerId), query)); + } + + @GetMapping("/{transferNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo) { + return ApiResponse.success(service.detail(scope(user, customerId), transferNo)); + } + + @PostMapping("/{transferNo}/review") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Review request) { + requireApprovalPermission(user); + return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/allocations") + public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Allocate request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.allocate(scope(user, customerId), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/outbound") + public ApiResponse outbound(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Outbound request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.outbound(scope(user, customerId), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/receipts") + public ApiResponse receive(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Receipt request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.receive(scope(user, customerId), transferNo, operator(user), request)); + } + + private void requireApprovalPermission(AuthenticatedUser user) { + if (user == null || user.permissions() == null + || !user.permissions().contains("lastleg:warehouse-transfer:approve")) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer approval permission is required"); + } + } + + private void requirePermission(AuthenticatedUser user, String permission) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); + } + } + + private String scope(AuthenticatedUser user, String requested) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requested == null || requested.isBlank() || "0".equals(requested)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requested.trim(); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java new file mode 100644 index 000000000..b05a53d11 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.transfer.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.DecimalMin; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class WarehouseTransferRequests { + private WarehouseTransferRequests() {} + + public record Create( + @NotBlank @Size(max = 128) String inboundCode, + @NotEmpty @Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds, + @NotBlank @Size(max = 128) String operatingWarehouseId, + @NotBlank @Size(max = 128) String fbaWarehouseCode, + @NotBlank @Size(max = 1000) String deliveryAddress, + @Positive int packageCount, + @NotNull @DecimalMin(value = "0.0001") BigDecimal volumeCbm, + @NotBlank @Size(max = 64) String deliveryMethod, + @NotNull LocalDateTime estimatedArrivalAt, + @NotBlank @Size(max = 128) String fbaShipmentNo, + @Size(max = 255) String trackingNo, + @Size(max = 2000) String fbaBoxNos, + @Size(max = 1000) String remark, + boolean needRelabel, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Item> items + ) {} + + public record Review(@NotBlank @Size(max = 32) String decision, + @Size(max = 1000) String remark) {} + + public record Allocate( + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Allocation> allocations + ) {} + + public record Allocation( + @NotNull Long inventoryPositionId, + @NotNull Long palletId, + @Positive int packageCount + ) {} + + public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Receipt( + @NotNull Long sourceInventoryPositionId, + @NotBlank @Size(max = 128) String destinationOverseasWarehouseId, + @Size(max = 128) String destinationOverseasWarehouseCode, + @Size(max = 255) String destinationOverseasWarehouseName, + @Size(max = 128) String destinationWarehouseId, + @NotBlank @Size(max = 128) String destinationWarehouseCode, + @Size(max = 255) String destinationWarehouseName, + @NotNull Long destinationPalletId, + @NotBlank @Size(max = 128) String destinationPalletNo, + @Size(max = 128) String batchNo, + Long warehouseLocationId, + @Size(max = 128) String warehouseLocationCode, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) {} + + public record Item( + @NotBlank @Size(max = 128) String skuCode, + @NotBlank @Size(max = 255) String productNameZh, + @Positive int packageCount, + @Size(max = 255) String shippingMark + ) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String transferNo, String inboundCode, String operatingWarehouseId, + String fbaWarehouseCode, String status, Boolean needRelabel, + LocalDateTime createdFrom, LocalDateTime createdTo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java new file mode 100644 index 000000000..09cd8e495 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.transfer.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class WarehouseTransferResponses { + private WarehouseTransferResponses() {} + public record Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {} + public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {} + public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode, + Long palletId, String palletNo, int packageCount, String status, + Long destinationInventoryPositionId) {} + public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId, + String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, + int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo, + String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} + public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, + Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, + String status, + boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus, + String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, + int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt, + String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, + List sourceOrders, List items, List allocations, + LocalDateTime outboundAt, LocalDateTime createdAt) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java new file mode 100644 index 000000000..1133aa030 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.transfer.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; + +@Data +@TableName("ll_warehouse_transfer_inventory_allocation") +public class WarehouseTransferInventoryAllocation { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private Long inventoryPositionId; + private Long inventoryBalanceId; + private Long inboundOrderId; + private String inboundCode; + private Long palletId; + private String palletNo; + private Integer packageCount; + private String status; + private String activePositionKey; + private LocalDateTime allocatedAt; + private LocalDateTime shippedAt; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java new file mode 100644 index 000000000..6ca13d301 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.transfer.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; + +@Data +@TableName("ll_warehouse_transfer_item") +public class WarehouseTransferItem { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private String skuCode; + private String productNameZh; + private Integer packageCount; + private String shippingMark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java new file mode 100644 index 000000000..56e8acae9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.transfer.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; + +@Data +@TableName("ll_warehouse_transfer_order") +public class WarehouseTransferOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String transferNo; + private Long inboundOrderId; + private String inboundCode; + private Long lastlegOrderId; + private String lastlegOrderNo; + private String operatingWarehouseId; + private String fbaWarehouseCode; + private String deliveryAddress; + private Integer packageCount; + private String deliveryMethod; + private LocalDateTime estimatedArrivalAt; + private String fbaShipmentNo; + private String trackingNo; + private String fbaBoxNos; + private String remark; + private Boolean needRelabel; + private Long relabelApplicationId; + private String relabelNo; + private String status; + private String allocationIdempotencyKey; + private String allocationRequestHash; + private String outboundIdempotencyKey; + private String outboundRequestHash; + private LocalDateTime outboundAt; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java new file mode 100644 index 000000000..ac50dafb5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.transfer.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; + +@Data +@TableName("ll_warehouse_transfer_receipt") +public class WarehouseTransferReceipt { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private Long allocationId; + private String customerId; + private Long sourceInventoryPositionId; + private Long destinationInventoryBalanceId; + private Long destinationInventoryPositionId; + private Integer packageCount; + private String idempotencyKey; + private String requestHash; + private String inventoryOperationKey; + private String createdBy; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java new file mode 100644 index 000000000..c760c7000 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.transfer.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; + +@Data +@TableName("ll_warehouse_transfer_source_order") +public class WarehouseTransferSourceOrder { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private Long sourceLastlegOrderId; + private String sourceLastlegOrderNo; + private Long inboundOrderId; + private String inboundCode; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java new file mode 100644 index 000000000..59e14637c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.transfer.mapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +@Mapper public interface InboundSnapshotMapper { + @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND (inbound_code=#{code} OR source_inbound_id=#{code}) LIMIT 1") + Long findId(@Param("customerId") String customerId, @Param("code") String code); + @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND source_inbound_id=#{sourceInboundId} LIMIT 1") + Long findBySourceInboundId(@Param("customerId") String customerId, @Param("sourceInboundId") String sourceInboundId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java new file mode 100644 index 000000000..858e80d15 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; +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 WarehouseTransferInventoryAllocationMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_inventory_allocation WHERE customer_id=#{customerId} " + + "AND transfer_order_id=#{transferOrderId} ORDER BY id FOR UPDATE") + List lockTransfer(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); + + @Select("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} " + + "AND warehouse_pallet_id=#{palletId} AND status='ACTIVE'") + int countActiveDispatchLock(@Param("customerId") String customerId, @Param("palletId") Long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java new file mode 100644 index 000000000..7de29cb2c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.transfer.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface WarehouseTransferItemMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java new file mode 100644 index 000000000..e062027c7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.transfer.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +@Mapper public interface WarehouseTransferOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + WarehouseTransferOrder findKey(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1") + WarehouseTransferOrder findNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1 FOR UPDATE") + WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java new file mode 100644 index 000000000..24f8b3ff1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface WarehouseTransferReceiptMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_receipt WHERE customer_id=#{customerId} " + + "AND transfer_order_id=#{transferOrderId} AND source_inventory_position_id=#{sourcePositionId} LIMIT 1") + WarehouseTransferReceipt findSource(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("sourcePositionId") Long sourcePositionId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java new file mode 100644 index 000000000..a501a5110 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.transfer.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface WarehouseTransferSourceOrderMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java new file mode 100644 index 000000000..8995b02bd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -0,0 +1,572 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class WarehouseTransferService { + public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER"; + public static final String RETURN_RELABEL_ORDER_TYPE = "RETURN_RELABEL"; + public static final String PENDING_REVIEW = "PENDING_REVIEW"; + public static final String APPROVED = "APPROVED"; + public static final String REJECTED = "REJECTED"; + public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT"; + public static final String ALLOCATED = "ALLOCATED"; + public static final String OUTBOUNDED = "OUTBOUNDED"; + public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED"; + public static final String RECEIVED = "RECEIVED"; + + @Resource private WarehouseTransferOrderMapper transfers; + @Resource private WarehouseTransferItemMapper transferItems; + @Resource private WarehouseTransferSourceOrderMapper sourceOrders; + @Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations; + @Resource private WarehouseTransferReceiptMapper transferReceipts; + @Resource private InboundSnapshotMapper inboundSnapshots; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper orderDetails; + @Resource private LastlegOrderStatusHistoryMapper orderHistories; + @Resource private RelabelApplicationMapper relabelApplications; + @Resource private RelabelItemMapper relabelItems; + @Resource private RelabelStatusHistoryMapper relabelHistories; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private InventoryCommandService inventory; + @Resource private InventoryCommandMapper inventoryCommands; + @Resource private ObjectMapper objectMapper; + + @Transactional + public WarehouseTransferResponses.Detail create(String customerId, String operatorId, + WarehouseTransferRequests.Create request) { + int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum(); + if (itemPackages != request.packageCount()) { + throw badRequest("Cargo item package total must equal transfer package count"); + } + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + WarehouseTransferOrder replay = transfers.findKey(customerId, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) { + throw conflict("Idempotency key was reused with different warehouse transfer data"); + } + return detail(customerId, replay.getTransferNo()); + } + + String inboundCode = request.inboundCode().trim(); + Long inboundOrderId = inboundSnapshots.findId(customerId, inboundCode); + if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode); + List selectedIds = request.sourceLastlegOrderIds().stream().distinct().toList(); + List selectedOrders = orders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds)); + if (selectedOrders.size() != selectedIds.size()) { + throw notFound("One or more selected customer orders do not exist in the current tenant"); + } + + LocalDateTime now = LocalDateTime.now(); + long transferId = IdWorker.getId(); + String transferNo = "WTR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + transferId; + LastlegOrder order = createTailOrder(customerId, inboundOrderId, transferNo, request, requestHash, now); + + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(transferId); transfer.setCustomerId(customerId); transfer.setTransferNo(transferNo); + transfer.setInboundOrderId(inboundOrderId); transfer.setInboundCode(inboundCode); + transfer.setLastlegOrderId(order.getId()); transfer.setLastlegOrderNo(order.getOrderNo()); + transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); + transfer.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); + transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount()); + transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); + transfer.setFbaShipmentNo(request.fbaShipmentNo().trim()); transfer.setTrackingNo(trim(request.trackingNo())); + transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark())); + transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW); + transfer.setIdempotencyKey(key); transfer.setRequestHash(requestHash); transfer.setCreatedBy(trim(operatorId)); + transfer.setCreatedAt(now); transfer.setUpdatedAt(now); + transfers.insert(transfer); + + for (LastlegOrder selectedOrder : selectedOrders) { + Long selectedInboundId = StringUtils.hasText(selectedOrder.getSourceInboundId()) + ? inboundSnapshots.findBySourceInboundId(customerId, selectedOrder.getSourceInboundId()) : null; + if (selectedInboundId == null) selectedInboundId = inboundSnapshots.findId(customerId, selectedOrder.getInboundCode()); + if (selectedInboundId == null) { + throw conflict("Selected order " + selectedOrder.getOrderNo() + " has no linked inbound order"); + } + WarehouseTransferSourceOrder link = new WarehouseTransferSourceOrder(); + link.setId(IdWorker.getId()); link.setTransferOrderId(transferId); link.setCustomerId(customerId); + link.setSourceLastlegOrderId(selectedOrder.getId()); link.setSourceLastlegOrderNo(selectedOrder.getOrderNo()); + link.setInboundOrderId(selectedInboundId); link.setInboundCode(selectedOrder.getInboundCode()); link.setCreatedAt(now); + sourceOrders.insert(link); + } + + for (int index = 0; index < request.items().size(); index++) { + WarehouseTransferRequests.Item source = request.items().get(index); + insertTransferItem(customerId, transferId, source, now); + insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); + } + insertOrderHistory(customerId, order.getId(), null, PENDING_REVIEW, transferNo, + "Warehouse transfer submitted for review", now); + if (request.needRelabel()) createRelabel(customerId, operatorId, transfer, order, request, now); + return detail(customerId, transferNo); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, WarehouseTransferRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { + throw badRequest("createdFrom must not be after createdTo"); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.transferNo()), WarehouseTransferOrder::getTransferNo, query.transferNo()) + .like(StringUtils.hasText(query.inboundCode()), WarehouseTransferOrder::getInboundCode, query.inboundCode()) + .eq(StringUtils.hasText(query.operatingWarehouseId()), WarehouseTransferOrder::getOperatingWarehouseId, query.operatingWarehouseId()) + .like(StringUtils.hasText(query.fbaWarehouseCode()), WarehouseTransferOrder::getFbaWarehouseCode, query.fbaWarehouseCode()) + .eq(StringUtils.hasText(query.status()), WarehouseTransferOrder::getStatus, query.status()) + .eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel()) + .ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) + .orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId); + Page result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper); + List records = result.getRecords().stream().map(value -> + new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), + value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), + value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(), + Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), + value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); + return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); + } + + @Transactional(readOnly = true) + public WarehouseTransferResponses.Detail detail(String customerId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + List items = transferItems.selectList( + new LambdaQueryWrapper().eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)) + .stream().map(value -> new WarehouseTransferResponses.Item(value.getSkuCode(), value.getProductNameZh(), + value.getPackageCount(), value.getShippingMark())).toList(); + List sources = sourceOrders.selectList( + new LambdaQueryWrapper().eq(WarehouseTransferSourceOrder::getCustomerId, customerId) + .eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferSourceOrder::getId)) + .stream().map(value -> new WarehouseTransferResponses.SourceOrder(value.getSourceLastlegOrderId(), + value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList(); + Map receiptByAllocation = transferReceipts.selectList( + new LambdaQueryWrapper().eq(WarehouseTransferReceipt::getCustomerId, customerId) + .eq(WarehouseTransferReceipt::getTransferOrderId, transfer.getId())) + .stream().collect(Collectors.toMap(WarehouseTransferReceipt::getAllocationId, Function.identity())); + List allocations = inventoryAllocations.selectList( + new LambdaQueryWrapper() + .eq(WarehouseTransferInventoryAllocation::getCustomerId, customerId) + .eq(WarehouseTransferInventoryAllocation::getTransferOrderId, transfer.getId()) + .orderByAsc(WarehouseTransferInventoryAllocation::getId)) + .stream().map(value -> { + WarehouseTransferReceipt receipt = receiptByAllocation.get(value.getId()); + return new WarehouseTransferResponses.Allocation(value.getInventoryPositionId(), value.getInboundOrderId(), + value.getInboundCode(), value.getPalletId(), value.getPalletNo(), value.getPackageCount(), + value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId()); + }).toList(); + String relabelStatus = null; + if (transfer.getRelabelNo() != null) { + RelabelApplication relabel = relabelApplications.findNo(customerId, transfer.getRelabelNo()); + relabelStatus = relabel == null ? null : relabel.getStatus(); + } + LastlegOrder tailOrder = orders.selectById(transfer.getLastlegOrderId()); + return new WarehouseTransferResponses.Detail(transfer.getId(), transfer.getTransferNo(), transfer.getInboundOrderId(), + transfer.getInboundCode(), transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), + tailOrder == null ? ORDER_TYPE : tailOrder.getOrderType(), + tailOrder == null ? (transfer.getStatus() == null ? PENDING_REVIEW : transfer.getStatus()) : tailOrder.getCurrentStatus(), + transfer.getStatus(), + Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(), + relabelStatus, + transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(), + transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(), + transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, allocations, + transfer.getOutboundAt(), transfer.getCreatedAt()); + } + + @Transactional + public WarehouseTransferResponses.Detail review(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Review request) { + String decision = request.decision().trim().toUpperCase(); + String next = switch (decision) { + case "APPROVE", APPROVED -> APPROVED; + case "REJECT", REJECTED -> REJECTED; + default -> throw badRequest("Review decision must be APPROVE or REJECT"); + }; + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!PENDING_REVIEW.equals(transfer.getStatus())) { + if (next.equals(transfer.getStatus())) return detail(customerId, transferNo); + throw conflict("Warehouse transfer order was already reviewed"); + } + LastlegOrder order = orders.selectById(transfer.getLastlegOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing"); + LocalDateTime now = LocalDateTime.now(); + String before = order.getCurrentStatus(); + transfer.setStatus(next); transfer.setUpdatedAt(now); transfers.updateById(transfer); + String orderStatus = APPROVED.equals(next) ? PENDING_ASSIGNMENT : "CANCELLED"; + order.setCurrentStatus(orderStatus); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next); + order.setUpdatedAt(now); orders.updateById(order); + if (!java.util.Objects.equals(before, orderStatus)) { + insertOrderHistory(customerId, order.getId(), before, orderStatus, transferNo, + "Warehouse transfer reviewed: " + next, now); + } + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail allocate(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Allocate request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + if (ALLOCATED.equals(transfer.getStatus()) && key.equals(transfer.getAllocationIdempotencyKey())) { + if (!requestHash.equals(transfer.getAllocationRequestHash())) { + throw conflict("Allocation idempotency key was reused with different inventory positions"); + } + return detail(customerId, transferNo); + } + if (!APPROVED.equals(transfer.getStatus())) { + throw conflict("Only an approved warehouse transfer can allocate inventory"); + } + List requested = request.allocations().stream() + .sorted(java.util.Comparator.comparing(WarehouseTransferRequests.Allocation::inventoryPositionId)).toList(); + Set uniquePositions = new HashSet<>(); + long totalPackages = 0; + for (WarehouseTransferRequests.Allocation allocation : requested) { + if (!uniquePositions.add(allocation.inventoryPositionId())) { + throw badRequest("Inventory positions must be unique within a warehouse transfer"); + } + totalPackages = Math.addExact(totalPackages, allocation.packageCount()); + } + if (totalPackages != transfer.getPackageCount()) { + throw badRequest("Allocated package total must equal warehouse transfer package count"); + } + Set sourceInboundIds = sourceOrders.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferSourceOrder::getCustomerId, customerId) + .eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId())) + .stream().map(WarehouseTransferSourceOrder::getInboundOrderId).collect(Collectors.toSet()); + if (sourceInboundIds.isEmpty()) throw conflict("Warehouse transfer has no source inbound orders"); + + LocalDateTime now = LocalDateTime.now(); + for (WarehouseTransferRequests.Allocation source : requested) { + InventoryPosition position = inventoryCommands.lockPosition(customerId, source.inventoryPositionId()); + if (position == null) throw notFound("Inventory position not found: " + source.inventoryPositionId()); + InventoryBalance balance = inventoryCommands.lockBalance(customerId, position.getBalanceId()); + if (balance == null) throw conflict("Inventory position balance is missing"); + if (!source.palletId().equals(position.getPalletId())) { + throw conflict("Inventory position does not belong to pallet " + source.palletId()); + } + if (inventoryAllocations.countActiveDispatchLock(customerId, position.getPalletId()) > 0) { + throw conflict("Inventory pallet is already locked by an active dispatch"); + } + if (!sourceInboundIds.contains(balance.getInboundOrderId())) { + throw conflict("Inventory position is not linked to a selected source inbound order"); + } + if (packages(position.getAvailablePackages()) < source.packageCount()) { + throw conflict("Inventory position does not have enough available packages"); + } + WarehouseTransferInventoryAllocation allocation = new WarehouseTransferInventoryAllocation(); + allocation.setId(IdWorker.getId()); allocation.setTransferOrderId(transfer.getId()); + allocation.setCustomerId(customerId); allocation.setInventoryPositionId(position.getId()); + allocation.setInventoryBalanceId(balance.getId()); allocation.setInboundOrderId(balance.getInboundOrderId()); + allocation.setInboundCode(balance.getInboundCode()); allocation.setPalletId(position.getPalletId()); + allocation.setPalletNo(position.getPalletNo()); allocation.setPackageCount(source.packageCount()); + allocation.setStatus("RESERVED"); allocation.setActivePositionKey(customerId + ":" + position.getId()); + allocation.setAllocatedAt(now); allocation.setCreatedAt(now); allocation.setUpdatedAt(now); + try { + inventoryAllocations.insert(allocation); + } catch (DuplicateKeyException exception) { + throw conflict("Inventory position is already reserved by another warehouse transfer"); + } + } + transfer.setStatus(ALLOCATED); transfer.setAllocationIdempotencyKey(key); + transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); + String before = order.getCurrentStatus(); order.setCurrentStatus("WAIT_OUTBOUND"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:ALLOCATED"); order.setUpdatedAt(now); + orders.updateById(order); + insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, + "Warehouse transfer inventory allocated", now); + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail outbound(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Outbound request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + if (Set.of(OUTBOUNDED, PARTIALLY_RECEIVED, RECEIVED).contains(transfer.getStatus()) + && key.equals(transfer.getOutboundIdempotencyKey())) { + if (!requestHash.equals(transfer.getOutboundRequestHash())) { + throw conflict("Outbound idempotency key was reused with different data"); + } + return detail(customerId, transferNo); + } + if (!ALLOCATED.equals(transfer.getStatus())) { + throw conflict("Warehouse transfer inventory must be allocated before outbound"); + } + List allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId()); + if (allocations.isEmpty() || allocations.stream().mapToLong(WarehouseTransferInventoryAllocation::getPackageCount).sum() + != transfer.getPackageCount()) { + throw conflict("Warehouse transfer inventory allocation is incomplete"); + } + LocalDateTime now = LocalDateTime.now(); + for (WarehouseTransferInventoryAllocation allocation : allocations) { + if (!"RESERVED".equals(allocation.getStatus())) throw conflict("Warehouse transfer allocation was already processed"); + String keyBase = "WAREHOUSE_TRANSFER:" + transferNo + ":" + allocation.getInventoryPositionId(); + InventoryCommandRequests.Move move = new InventoryCommandRequests.Move( + allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, + "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark())); + inventory.allocate(customerId, operatorId, move); + inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move( + allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, + "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark()))); + inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move( + allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, + "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark()))); + allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null); + allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation); + } + transfer.setStatus(OUTBOUNDED); transfer.setOutboundIdempotencyKey(key); + transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now); + transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); + String before = order.getCurrentStatus(); order.setCurrentStatus("IN_TRANSIT"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now); + orders.updateById(order); + insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, + "Warehouse transfer left source warehouse", now); + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail receive(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Receipt request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!Set.of(OUTBOUNDED, PARTIALLY_RECEIVED).contains(transfer.getStatus())) { + if (RECEIVED.equals(transfer.getStatus())) { + WarehouseTransferReceipt replay = transferReceipts.findSource(customerId, transfer.getId(), + request.sourceInventoryPositionId()); + if (replay != null && replay.getRequestHash().equals(hash(request))) return detail(customerId, transferNo); + } + throw conflict("Only outbound warehouse transfer inventory can be received"); + } + List allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId()); + WarehouseTransferInventoryAllocation allocation = allocations.stream() + .filter(value -> request.sourceInventoryPositionId().equals(value.getInventoryPositionId())) + .findFirst().orElseThrow(() -> notFound("Source inventory position is not allocated to this transfer")); + String requestHash = hash(request); + WarehouseTransferReceipt existing = transferReceipts.findSource(customerId, transfer.getId(), + request.sourceInventoryPositionId()); + if (existing != null) { + if (!requestHash.equals(existing.getRequestHash())) { + throw conflict("Transfer source position was already received with different destination data"); + } + return detail(customerId, transferNo); + } + if (!"SHIPPED".equals(allocation.getStatus())) throw conflict("Transfer allocation is not awaiting receipt"); + + String operationKey = "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo + ":" + allocation.getInventoryPositionId(); + InventoryBalance sourceBalance = inventoryCommands.lockBalance(customerId, allocation.getInventoryBalanceId()); + if (sourceBalance == null || !customerId.equals(sourceBalance.getCustomerId())) { + throw conflict("Source inventory balance is missing for warehouse transfer receipt"); + } + InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, + new InventoryCommandRequests.StockIn(customerId, null, + request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()), + trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()), + request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()), + transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo, + sourceBalance.getSkuCode(), sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(), + request.destinationPalletId(), request.destinationPalletNo().trim(), + trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), request.warehouseLocationId(), + trim(request.warehouseLocationCode()), allocation.getPackageCount(), transferNo, + "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo, operationKey, trim(request.remark()))); + LocalDateTime now = LocalDateTime.now(); + WarehouseTransferReceipt receipt = new WarehouseTransferReceipt(); + receipt.setId(IdWorker.getId()); receipt.setTransferOrderId(transfer.getId()); receipt.setAllocationId(allocation.getId()); + receipt.setCustomerId(customerId); receipt.setSourceInventoryPositionId(allocation.getInventoryPositionId()); + receipt.setDestinationInventoryBalanceId(result.balanceId()); receipt.setDestinationInventoryPositionId(result.positionId()); + receipt.setPackageCount(allocation.getPackageCount()); receipt.setIdempotencyKey(request.idempotencyKey().trim()); + receipt.setRequestHash(requestHash); receipt.setInventoryOperationKey(operationKey); receipt.setCreatedBy(trim(operatorId)); + receipt.setReceivedAt(now); receipt.setCreatedAt(now); transferReceipts.insert(receipt); + allocation.setStatus("RECEIVED"); allocation.setReceivedAt(now); allocation.setUpdatedAt(now); + inventoryAllocations.updateById(allocation); + + boolean complete = allocations.stream().allMatch(value -> value.getId().equals(allocation.getId()) + || "RECEIVED".equals(value.getStatus())); + transfer.setStatus(complete ? RECEIVED : PARTIALLY_RECEIVED); transfer.setUpdatedAt(now); transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); + String before = order.getCurrentStatus(); order.setCurrentStatus(complete ? "COMPLETED" : "IN_TRANSIT"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + (complete ? RECEIVED : PARTIALLY_RECEIVED)); + order.setUpdatedAt(now); orders.updateById(order); + insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, + "Warehouse transfer inventory received at destination", now); + return detail(customerId, transferNo); + } + + private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo, + WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) { + LastlegOrder order = new LastlegOrder(); + order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next()); order.setCustomerId(customerId); + order.setOrderType(request.needRelabel() ? RETURN_RELABEL_ORDER_TYPE : ORDER_TYPE); + order.setInboundCode(request.inboundCode().trim()); order.setSourceRegion("CUSTOMER"); + order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId)); + order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); + order.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); order.setPackageCount(request.packageCount()); + order.setVolumeCbm(request.volumeCbm()); + order.setContainerNo(transferNo); + order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0); + order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); + order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW); + order.setCustomerRemark(trim(request.remark())); + order.setSourceEventId("WAREHOUSE_TRANSFER:" + transferNo); order.setSourceLoadingPlanId(ORDER_TYPE); + order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now); + orders.insert(order); + return order; + } + + private void insertTransferItem(String customerId, long transferId, WarehouseTransferRequests.Item source, + LocalDateTime now) { + WarehouseTransferItem item = new WarehouseTransferItem(); + item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId); + item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim()); + item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now); + transferItems.insert(item); + } + + private void insertOrderDetail(String customerId, long orderId, String transferNo, int index, WarehouseTransferRequests.Item source, + WarehouseTransferRequests.Create request, LocalDateTime now) { + LastlegOrderDetail detail = new LastlegOrderDetail(); + detail.setCustomerId(customerId); + detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index); + detail.setSkuCode(source.skuCode().trim()); detail.setProductNameZh(source.productNameZh().trim()); + detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(request.fbaWarehouseCode().trim()); + detail.setFbaTrackingNo(trim(request.trackingNo())); detail.setShipmentNo(request.fbaShipmentNo().trim()); + detail.setShippingMark(trim(source.shippingMark())); orderDetails.insert(detail); + } + + private void createRelabel(String customerId, String operatorId, WarehouseTransferOrder transfer, LastlegOrder order, + WarehouseTransferRequests.Create request, LocalDateTime now) { + long applicationId = IdWorker.getId(); + String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId; + RelabelApplication application = new RelabelApplication(); + application.setId(applicationId); application.setCustomerId(customerId); application.setRelabelNo(relabelNo); + application.setLastlegOrderId(order.getId()); application.setCustomerOrderNo(transfer.getTransferNo()); + application.setOverseasWarehouseId(request.operatingWarehouseId().trim()); application.setLabelDeadline(request.estimatedArrivalAt()); + application.setContactName(StringUtils.hasText(operatorId) ? operatorId.trim() : "SYSTEM"); + application.setContactPhone("N/A"); application.setRemark(trim(request.remark())); + application.setStatus(RelabelApplicationService.PENDING_PROCESSING); application.setItemCount(request.items().size()); + application.setTotalPackageCount(request.packageCount()); application.setNotifiedPackageCount(request.packageCount()); + application.setIdempotencyKey("WAREHOUSE_TRANSFER:" + request.idempotencyKey().trim()); + application.setRequestHash(hash(request)); application.setCreatedBy(trim(operatorId)); + application.setCreatedAt(now); application.setUpdatedAt(now); relabelApplications.insert(application); + + for (WarehouseTransferRequests.Item source : request.items()) { + RelabelItem item = new RelabelItem(); + item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId); + item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim()); + item.setTotalPackageCount(source.packageCount()); item.setNotifiedPackageCount(source.packageCount()); + item.setShipmentNo(request.fbaShipmentNo().trim()); item.setTrackingCode(trim(request.trackingNo())); + item.setNewSkuCode(source.skuCode().trim()); item.setLabelRequirement("WAREHOUSE_TRANSFER_RELABEL"); + item.setCreatedAt(now); relabelItems.insert(item); + } + RelabelStatusHistory history = new RelabelStatusHistory(); + history.setId(IdWorker.getId()); history.setCustomerId(customerId); history.setApplicationId(applicationId); + history.setToStatus(RelabelApplicationService.PENDING_PROCESSING); history.setOperatorId(trim(operatorId)); + history.setRemark("Created from warehouse transfer " + transfer.getTransferNo()); history.setCreatedAt(now); + relabelHistories.insert(history); + transfer.setRelabelApplicationId(applicationId); transfer.setRelabelNo(relabelNo); transfer.setUpdatedAt(now); + transfers.updateById(transfer); + order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(RelabelApplicationService.PENDING_PROCESSING); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + RelabelApplicationService.PENDING_PROCESSING); + order.setUpdatedAt(now); orders.updateById(order); + } + + private void insertOrderHistory(String customerId, long orderId, String fromStatus, String toStatus, + String transferNo, String remark, LocalDateTime now) { + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setCustomerId(customerId); + history.setOrderId(orderId); history.setFromStatus(fromStatus); history.setToStatus(toStatus); + history.setEventId("WAREHOUSE_TRANSFER:" + transferNo); history.setRemark(remark); + history.setCreatedAt(now); orderHistories.insert(history); + } + + private LastlegOrder requireTailOrder(String customerId, WarehouseTransferOrder transfer) { + LastlegOrder order = orders.selectById(transfer.getLastlegOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) { + throw conflict("Linked tail-leg order is missing"); + } + return order; + } + + private String hash(Object value) { + try { + byte[] json = objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static long packages(Long value) { return value == null ? 0L : value; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/TruckingMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/TruckingMapperConfiguration.java new file mode 100644 index 000000000..4b9ecc170 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/TruckingMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.trucking; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.trucking.mapper") +public class TruckingMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java new file mode 100644 index 000000000..2435b98c0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.trucking.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; +import com.baiyue.lastleg.trucking.service.UsTruckOrderService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/us-truck-orders") +public class MerchantUsTruckOrderController { + @Resource private UsTruckOrderService service; + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody UsTruckOrderRequests.Create request) { + return ApiResponse.success(service.create(customer(user), operator(user), request)); + } + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid UsTruckOrderRequests.Query query) { + return ApiResponse.success(service.page(customer(user), query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String orderNo) { + return ApiResponse.success(service.detail(customer(user), orderNo)); + } + + @PatchMapping("/{orderNo}") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) { + return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request)); + } + + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java new file mode 100644 index 000000000..b1afcaa80 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java @@ -0,0 +1,181 @@ +package com.baiyue.lastleg.trucking.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; +import com.baiyue.lastleg.trucking.service.UsTruckOrderService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/us-truck-orders") +public class PlatformUsTruckOrderController { + @Resource private UsTruckOrderService service; + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_LIST + "')") + @Operation(summary = "Page local truck orders", description = "Required authority: " + OverseasPermissions.TRUCK_LIST) + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid UsTruckOrderRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.page(null, query)); + } + + @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_CREATE + "')") + @Operation(summary = "Create a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_CREATE) + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @Valid @RequestBody UsTruckOrderRequests.Create request) { + requirePermission(user, OverseasPermissions.TRUCK_CREATE); + return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request)); + } + + @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_LIST + "')") + @Operation(summary = "Get a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_LIST) + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo) { + requirePlatform(user); + return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo)); + } + + @PatchMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_EDIT + "')") + @Operation(summary = "Update a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_EDIT) + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.Update request) { + requirePermission(user, OverseasPermissions.TRUCK_EDIT); + return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PatchMapping("/{orderNo}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + @Operation(summary = "Change local truck order status", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS) + public ApiResponse changeStatus(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/review") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.Review request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/receive-pallets") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + @Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS) + public ApiResponse receivePallets(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/review-pallet-overage") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse reviewPalletOverage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ReviewPalletOverage request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.reviewPalletOverage( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/allocate-pallets") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse allocatePallets( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.AllocatePallets request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.allocatePallets( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/prepare-dispatch") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse prepareDispatch( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.prepareDispatch( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/dispatch") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse dispatch( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.dispatch( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/start-transit") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse startTransit( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.startTransit( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/complete") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse complete( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.complete( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + } + + private void requirePermission(AuthenticatedUser user, String permission) { + requirePlatform(user); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); + } + } + + private String requiredCustomer(String customerId) { + if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required"); + } + return customerId.trim(); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java new file mode 100644 index 000000000..9dc8d9646 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java @@ -0,0 +1,116 @@ +package com.baiyue.lastleg.trucking.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public final class UsTruckOrderRequests { + private UsTruckOrderRequests() {} + + public record Create( + @Size(max = 32) String transportType, + @NotBlank @Size(max = 255) String origin, + @Size(max = 500) String originAddress, + @NotBlank @Size(max = 255) String destination, + @Size(max = 500) String destinationAddress, + @Size(max = 32) String containerType, + @NotNull @Min(1) Integer palletCount, + @Min(0) Integer packageCount, + @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, + @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, + @DecimalMin("0.00") BigDecimal budgetAmount, + @Size(min = 3, max = 3) String currency, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 128) String contactInfo, + @NotNull LocalDate requestedDate, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey) { + /** Backward-compatible constructor for clients still sending legacy transport fields. */ + public Create(String transportType, String origin, String originAddress, String destination, + String destinationAddress, String containerType, Integer packageCount, + BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, + String currency, String contactName, String contactInfo, LocalDate requestedDate, + String remark, String idempotencyKey) { + this(transportType, origin, originAddress, destination, destinationAddress, containerType, + packageCount == null || packageCount < 1 ? 1 : packageCount, packageCount, + totalWeightKg, totalVolumeCbm, budgetAmount, currency, contactName, contactInfo, + requestedDate, remark, idempotencyKey); + } + } + + public record Query( + @Min(1) long page, @Min(1) @Max(200) long size, + String orderNo, String routeKeyword, String status, String transportType, + String customerId, LocalDate requestedFrom, LocalDate requestedTo) {} + + public record Update( + @NotNull @Min(0) Long version, + @Size(max = 32) String transportType, + @Size(max = 255) String origin, + @Size(max = 500) String originAddress, + @Size(max = 255) String destination, + @Size(max = 500) String destinationAddress, + @Size(max = 32) String containerType, + @Min(1) Integer palletCount, + @Min(0) Integer packageCount, + @DecimalMin("0.00") BigDecimal totalWeightKg, + @DecimalMin("0.00") BigDecimal totalVolumeCbm, + @DecimalMin("0.00") BigDecimal budgetAmount, + @Size(min = 3, max = 3) String currency, + @Size(max = 128) String contactName, + @Size(max = 128) String contactInfo, + LocalDate requestedDate, + @Size(max = 1000) String remark) {} + + public record ChangeStatus( + @NotNull @Min(0) Long version, + @NotBlank @Pattern(regexp = "CANCELLED") String status, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Review( + @NotNull @Min(0) Long version, + @NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record ReviewPalletOverage( + @NotNull @Min(0) Long version, + @NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record AllocatePallets( + @NotNull @Min(0) Long version, + @NotEmpty @Size(max = 500) List<@NotNull @Positive Long> palletIds, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Command( + @NotNull @Min(0) Long version, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record ReceivePallets( + @NotNull @Min(0) Long version, + @NotNull @Min(1) Integer actualPalletCount, + @Min(0) Integer packageCount, + @Size(max = 500) List<@Min(0) Integer> palletPackageCounts, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { + public ReceivePallets(Long version, Integer actualPalletCount, Integer packageCount, + String idempotencyKey, String remark) { + this(version, actualPalletCount, packageCount, List.of(), idempotencyKey, remark); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java new file mode 100644 index 000000000..2370d6afe --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.trucking.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class UsTruckOrderResponses { + private UsTruckOrderResponses() {} + + public record Summary( + Long id, String customerId, String orderNo, String transportType, + String origin, String destination, String containerType, int palletCount, int packageCount, + int receivedPalletCount, int allocatedPalletCount, + BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, + String currency, String contactName, String contactInfo, LocalDate requestedDate, + String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} + + public record History( + String operationType, String fromStatus, String toStatus, String operatorId, + String idempotencyKey, String remark, LocalDateTime createdAt) {} + + public record Pallet(Long id, String palletNo, int sequenceNo, String source, String status, + Long warehousePalletId, Integer packageCount, LocalDateTime receivedAt) {} + + public record Detail( + Summary summary, String originAddress, String destinationAddress, String remark, + String createdBy, List pallets, List histories) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java new file mode 100644 index 000000000..6f6d5eb79 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.trucking.entity; + +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.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_us_truck_order") +public class UsTruckOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String orderNo; + private String transportType; + private String origin; + private String originAddress; + private String destination; + private String destinationAddress; + private String containerType; + private Integer palletCount; + private Integer packageCount; + private Integer receivedPalletCount; + private Integer allocatedPalletCount; + private BigDecimal totalWeightKg; + private BigDecimal totalVolumeCbm; + private BigDecimal budgetAmount; + private String currency; + private String contactName; + private String contactInfo; + private LocalDate requestedDate; + private String status; + private String remark; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java new file mode 100644 index 000000000..a9bfb4087 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.trucking.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; + +@Data +@TableName("ll_us_truck_order_operation") +public class UsTruckOrderOperation { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String operationType; + private String idempotencyKey; + private String requestHash; + private Long resultVersion; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java new file mode 100644 index 000000000..593e7047b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.trucking.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; + +@Data +@TableName("ll_us_truck_order_pallet") +public class UsTruckOrderPallet { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String palletNo; + private Integer sequenceNo; + private String source; + private String status; + private Long warehousePalletId; + private Integer packageCount; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java new file mode 100644 index 000000000..3f4602de5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.trucking.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; + +@Data +@TableName("ll_us_truck_order_status_history") +public class UsTruckOrderStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String operationType; + private String fromStatus; + private String toStatus; + private String operatorId; + private String idempotencyKey; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java new file mode 100644 index 000000000..f6c43085d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface UsTruckOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1") + UsTruckOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); + + @Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + UsTruckOrder findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE") + UsTruckOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java new file mode 100644 index 000000000..92d12f53e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface UsTruckOrderOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1") + UsTruckOrderOperation findKey(@Param("customerId") String customerId, + @Param("orderId") Long orderId, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java new file mode 100644 index 000000000..3eecc0897 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet; +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 UsTruckOrderPalletMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order_pallet WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY sequence_no") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java new file mode 100644 index 000000000..8875ec5b1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; +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 UsTruckOrderStatusHistoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id") + List listOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java new file mode 100644 index 000000000..fe586c4da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -0,0 +1,450 @@ +package com.baiyue.lastleg.trucking.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper; +import com.baiyue.lastleg.security.OverseasPermissions; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +@Service +public class UsTruckOrderService { + public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS; + public static final String PENDING_REVIEW = "PENDING_REVIEW"; + /** Kept for source compatibility; new orders use PENDING_REVIEW. */ + @Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW; + public static final String WAITING_INBOUND = "WAITING_INBOUND"; + public static final String RECEIVING = "RECEIVING"; + public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION"; + public static final String PALLET_OVERAGE_REVIEW = "PALLET_OVERAGE_REVIEW"; + public static final String PARTIALLY_ALLOCATED = "PARTIALLY_ALLOCATED"; + public static final String ALLOCATED = "ALLOCATED"; + public static final String PENDING_DISPATCH = "PENDING_DISPATCH"; + public static final String DISPATCHED = "DISPATCHED"; + public static final String IN_TRANSIT = "IN_TRANSIT"; + public static final String COMPLETED = "COMPLETED"; + public static final String REJECTED = "REJECTED"; + public static final String CANCELLED = "CANCELLED"; + private static final Set STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, + WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, + PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED); + private static final Set CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, + PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH); + private static final Set TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR"); + + @Resource private UsTruckOrderMapper orders; + @Resource private UsTruckOrderOperationMapper operations; + @Resource private UsTruckOrderPalletMapper pallets; + @Resource private UsTruckOrderStatusHistoryMapper histories; + @Resource private ObjectMapper objectMapper; + + @Transactional + public UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request) { + String key = request.idempotencyKey().trim(); + String hash = hash(request); + UsTruckOrder replay = orders.findKey(customerId, key); + if (replay != null) return replay(customerId, replay, hash); + if (StringUtils.hasText(request.transportType())) validateTransport(request.transportType()); + LocalDateTime now = LocalDateTime.now(); + UsTruckOrder order = new UsTruckOrder(); + order.setId(IdWorker.getId()); + order.setCustomerId(customerId); + order.setOrderNo("TK" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId()); + order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null); + order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); + order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); + order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null); + order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount())); + order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); + order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); + order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency())); + order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim()); + order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_REVIEW); + order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash); + order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now); + try { orders.insert(order); } + catch (DuplicateKeyException e) { + UsTruckOrder concurrent = orders.findKey(customerId, key); + if (concurrent != null) return replay(customerId, concurrent, hash); + throw e; + } + history(order, "CREATE", null, PENDING_REVIEW, operatorId, key, request.remark(), now); + return detail(customerId, order.getOrderNo()); + } + + public PageResult page(String customerId, UsTruckOrderRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper w = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), UsTruckOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.orderNo()), UsTruckOrder::getOrderNo, query.orderNo()) + .and(StringUtils.hasText(query.routeKeyword()), x -> x.like(UsTruckOrder::getOrigin, query.routeKeyword()) + .or().like(UsTruckOrder::getDestination, query.routeKeyword())) + .eq(StringUtils.hasText(query.status()), UsTruckOrder::getStatus, + StringUtils.hasText(query.status()) ? norm(query.status()) : null) + .eq(StringUtils.hasText(query.transportType()), UsTruckOrder::getTransportType, + StringUtils.hasText(query.transportType()) ? norm(query.transportType()) : null) + .eq(StringUtils.hasText(query.customerId()), UsTruckOrder::getCustomerId, query.customerId()) + .ge(query.requestedFrom() != null, UsTruckOrder::getRequestedDate, query.requestedFrom()) + .le(query.requestedTo() != null, UsTruckOrder::getRequestedDate, query.requestedTo()) + .orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId); + Page result = orders.selectPage(Page.of(query.page(), query.size()), w); + return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(), + result.getSize(), result.getTotal(), result.getPages()); + } + + public UsTruckOrderResponses.Detail detail(String customerId, String orderNo) { + UsTruckOrder order = orders.findNo(customerId, orderNo); + if (order == null) throw notFound(); + return detail(order, histories.listOrder(customerId, order.getId())); + } + + @Transactional + public UsTruckOrderResponses.Detail update(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Update request) { + UsTruckOrder order = lock(customerId, orderNo); + if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited"); + if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); } + setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress); + setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress); + setText(request.containerType(), v -> order.setContainerType(norm(v))); + if (request.palletCount() != null) order.setPalletCount(request.palletCount()); + if (request.packageCount() != null) order.setPackageCount(request.packageCount()); + if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm()); + if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency())); + setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo); + if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark); + touch(order); orders.updateById(order); + history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, "UPDATE", LocalDateTime.now()); + return detail(customerId, orderNo); + } + + @Transactional + public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.ChangeStatus request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, "CANCEL", request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!CANCELLED.equals(norm(request.status())) || !CANCELLABLE.contains(order.getStatus())) { + throw conflict("Only cancellable truck orders can use the generic status endpoint"); + } + return transition(order, operatorId, "CANCEL", request.idempotencyKey(), request, + order.getStatus(), CANCELLED, request.remark()); + } + + @Transactional + public UsTruckOrderResponses.Detail review(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Review request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW", request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending-review truck orders can be reviewed"); + String next = "APPROVE".equals(norm(request.decision())) ? WAITING_INBOUND : REJECTED; + if (WAITING_INBOUND.equals(next)) createPlannedPallets(order); + return transition(order, operatorId, "REVIEW", request.idempotencyKey(), request, + PENDING_REVIEW, next, request.remark()); + } + + @Transactional + public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.ReceivePallets request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) { + throw conflict("Only waiting or receiving orders can receive pallets"); + } + + createPlannedPallets(order); + List current = pallets.listOrder(customerId, order.getId()); + int planned = value(order.getPalletCount()); + int actual = request.actualPalletCount(); + List packageCounts = request.palletPackageCounts() == null ? List.of() + : request.palletPackageCounts(); + if (!packageCounts.isEmpty() && packageCounts.size() != actual) { + throw badRequest("palletPackageCounts must be empty or contain one value per actual pallet"); + } + if (!packageCounts.isEmpty() && request.packageCount() != null + && packageCounts.stream().mapToInt(Integer::intValue).sum() != request.packageCount()) { + throw badRequest("Per-pallet package counts must equal the received package total"); + } + LocalDateTime now = LocalDateTime.now(); + for (int sequence = 1; sequence <= actual; sequence++) { + UsTruckOrderPallet pallet = sequence <= current.size() ? current.get(sequence - 1) + : newPallet(order, sequence, "PDA_OVERAGE", now); + pallet.setStatus(sequence > planned ? "OVERAGE_PENDING_REVIEW" : "RECEIVED"); + if (!packageCounts.isEmpty()) pallet.setPackageCount(packageCounts.get(sequence - 1)); + else if (actual == 1) pallet.setPackageCount(request.packageCount()); + pallet.setReceivedAt(now); + pallet.setUpdatedAt(now); + if (pallet.getId() == null) { + pallet.setId(IdWorker.getId()); + pallets.insert(pallet); + } else { + pallets.updateById(pallet); + } + } + order.setReceivedPalletCount(actual); + String from = order.getStatus(); + String next = actual > planned ? PALLET_OVERAGE_REVIEW + : actual == planned ? WAITING_CONSOLIDATION : RECEIVING; + order.setStatus(next); + touch(order); + orders.updateById(order); + recordOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request, operatorId, now); + history(order, "RECEIVE_PALLETS", from, next, operatorId, request.idempotencyKey(), request.remark(), now); + return detail(customerId, orderNo); + } + + @Transactional + public UsTruckOrderResponses.Detail reviewPalletOverage(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.ReviewPalletOverage request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW_PALLET_OVERAGE", + request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!PALLET_OVERAGE_REVIEW.equals(order.getStatus())) { + throw conflict("Only pallet-overage-review orders can be reviewed"); + } + boolean approve = "APPROVE".equals(norm(request.decision())); + List rows = pallets.listOrder(customerId, order.getId()); + LocalDateTime now = LocalDateTime.now(); + int accepted = value(order.getPalletCount()); + for (UsTruckOrderPallet pallet : rows) { + if (!"OVERAGE_PENDING_REVIEW".equals(pallet.getStatus())) continue; + pallet.setStatus(approve ? "RECEIVED" : "OVERAGE_REJECTED"); + pallet.setUpdatedAt(now); + pallets.updateById(pallet); + } + if (approve) { + accepted = value(order.getReceivedPalletCount()); + order.setPalletCount(accepted); + } else { + order.setReceivedPalletCount(accepted); + } + order.setStatus(WAITING_CONSOLIDATION); + touch(order); + orders.updateById(order); + recordOperation(order, "REVIEW_PALLET_OVERAGE", request.idempotencyKey(), request, operatorId, now); + history(order, "REVIEW_PALLET_OVERAGE", PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, + operatorId, request.idempotencyKey(), request.remark(), now); + return detail(customerId, orderNo); + } + + @Transactional + public UsTruckOrderResponses.Detail allocatePallets(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.AllocatePallets request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, "ALLOCATE_PALLETS", + request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!Set.of(WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED).contains(order.getStatus())) { + throw conflict("Only waiting-consolidation or partially-allocated orders can allocate pallets"); + } + Set requested = new HashSet<>(request.palletIds()); + if (requested.size() != request.palletIds().size()) throw badRequest("palletIds must be unique"); + List rows = pallets.listOrder(customerId, order.getId()); + List selected = rows.stream().filter(row -> requested.contains(row.getId())).toList(); + if (selected.size() != requested.size() || selected.stream().anyMatch(row -> !"RECEIVED".equals(row.getStatus()))) { + throw conflict("Selected pallets must belong to the order and be received but unallocated"); + } + LocalDateTime now = LocalDateTime.now(); + for (UsTruckOrderPallet pallet : selected) { + pallet.setStatus("ALLOCATED"); + pallet.setUpdatedAt(now); + pallets.updateById(pallet); + } + int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count(); + int accepted = value(order.getReceivedPalletCount()); + if (accepted < 1 || allocated > accepted) throw conflict("Allocated pallet count exceeds accepted pallets"); + String from = order.getStatus(); + String next = allocated == accepted ? ALLOCATED : PARTIALLY_ALLOCATED; + order.setAllocatedPalletCount(allocated); + order.setStatus(next); + touch(order); + orders.updateById(order); + recordOperation(order, "ALLOCATE_PALLETS", request.idempotencyKey(), request, operatorId, now); + history(order, "ALLOCATE_PALLETS", from, next, operatorId, + request.idempotencyKey(), request.remark(), now); + return detail(customerId, orderNo); + } + + @Transactional + public UsTruckOrderResponses.Detail prepareDispatch(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "PREPARE_DISPATCH", ALLOCATED, + PENDING_DISPATCH, request); + } + + @Transactional + public UsTruckOrderResponses.Detail dispatch(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "DISPATCH", PENDING_DISPATCH, DISPATCHED, request); + } + + @Transactional + public UsTruckOrderResponses.Detail startTransit(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "START_TRANSIT", DISPATCHED, IN_TRANSIT, request); + } + + @Transactional + public UsTruckOrderResponses.Detail complete(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request); + } + + private UsTruckOrderResponses.Detail command(String customerId, String orderNo, String operatorId, + String operationType, String expectedStatus, String nextStatus, + UsTruckOrderRequests.Command request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, operationType, + request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!expectedStatus.equals(order.getStatus())) { + throw conflict("Truck order must be " + expectedStatus + " before " + operationType); + } + if (ALLOCATED.equals(expectedStatus) + && value(order.getAllocatedPalletCount()) != value(order.getReceivedPalletCount())) { + throw conflict("All accepted pallets must be allocated before dispatch preparation"); + } + return transition(order, operatorId, operationType, request.idempotencyKey(), request, + expectedStatus, nextStatus, request.remark()); + } + + private UsTruckOrderResponses.Detail transition(UsTruckOrder order, String operatorId, + String operationType, String idempotencyKey, Object request, + String from, String to, String remark) { + LocalDateTime now = LocalDateTime.now(); + order.setStatus(to); + touch(order); + orders.updateById(order); + recordOperation(order, operationType, idempotencyKey, request, operatorId, now); + history(order, operationType, from, to, operatorId, idempotencyKey, remark, now); + return detail(order.getCustomerId(), order.getOrderNo()); + } + + private UsTruckOrderResponses.Detail replayOperation(UsTruckOrder order, String operationType, + String idempotencyKey, Object request) { + String key = idempotencyKey.trim(); + UsTruckOrderOperation existing = operations.findKey(order.getCustomerId(), order.getId(), key); + if (existing == null) return null; + if (!operationType.equals(existing.getOperationType()) || !hash(request).equals(existing.getRequestHash())) { + throw conflict("Idempotency key was reused with a different truck operation payload"); + } + return detail(order.getCustomerId(), order.getOrderNo()); + } + + private void recordOperation(UsTruckOrder order, String operationType, String idempotencyKey, + Object request, String operatorId, LocalDateTime now) { + UsTruckOrderOperation operation = new UsTruckOrderOperation(); + operation.setId(IdWorker.getId()); + operation.setOrderId(order.getId()); + operation.setCustomerId(order.getCustomerId()); + operation.setOperationType(operationType); + operation.setIdempotencyKey(idempotencyKey.trim()); + operation.setRequestHash(hash(request)); + operation.setResultVersion(order.getVersion()); + operation.setCreatedBy(operatorId); + operation.setCreatedAt(now); + operations.insert(operation); + } + + private void requireVersion(UsTruckOrder order, Long requestedVersion) { + if (!requestedVersion.equals(value(order.getVersion()))) { + throw conflict("Order was changed; refresh and retry"); + } + } + + private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; } + private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); } + private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List hs) { + List palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream() + .map(this::pallet) + .toList(); + return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), + o.getRemark(), o.getCreatedBy(), palletDetails, + hs.stream().map(h -> new UsTruckOrderResponses.History(h.getOperationType(), + h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getIdempotencyKey(), + h.getRemark(), h.getCreatedAt())).toList()); + } + private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) { + return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()), + p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt()); + } + private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); } + private void history(UsTruckOrder o, String operationType, String from, String to, String operator, + String idempotencyKey, String remark, LocalDateTime at) { + UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); + h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); + h.setOperationType(operationType); h.setFromStatus(from); h.setToStatus(to); + h.setOperatorId(operator); h.setIdempotencyKey(trim(idempotencyKey)); h.setRemark(trim(remark)); + h.setCreatedAt(at); histories.insert(h); + } + private void createPlannedPallets(UsTruckOrder order) { + int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size(); + LocalDateTime now = LocalDateTime.now(); + for (int sequence = existing + 1; sequence <= value(order.getPalletCount()); sequence++) { + UsTruckOrderPallet pallet = newPallet(order, sequence, "ORDER_PLAN", now); + pallet.setId(IdWorker.getId()); + pallets.insert(pallet); + } + } + private UsTruckOrderPallet newPallet(UsTruckOrder order, int sequence, String source, LocalDateTime now) { + UsTruckOrderPallet pallet = new UsTruckOrderPallet(); + pallet.setOrderId(order.getId()); pallet.setCustomerId(order.getCustomerId()); + pallet.setPalletNo(order.getOrderNo() + "-P" + String.format("%03d", sequence)); + pallet.setSequenceNo(sequence); pallet.setSource(source); pallet.setStatus("EXPECTED"); + pallet.setCreatedAt(now); pallet.setUpdatedAt(now); + return pallet; + } + private void validateQuery(UsTruckOrderRequests.Query q) { if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); if (StringUtils.hasText(q.transportType())) validateTransport(q.transportType()); if (q.requestedFrom() != null && q.requestedTo() != null && q.requestedFrom().isAfter(q.requestedTo())) throw badRequest("requestedFrom must not be after requestedTo"); } + private void validateTransport(String v) { if (!TRANSPORT_TYPES.contains(norm(v))) throw badRequest("Unsupported transport type"); } + private String hash(Object v) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(v))); } catch (JsonProcessingException | NoSuchAlgorithmException e) { throw new IllegalStateException("Cannot hash truck request", e); } } + private static void touch(UsTruckOrder o) { o.setVersion(value(o.getVersion()) + 1); o.setUpdatedAt(LocalDateTime.now()); } + private static void setText(String v, Consumer c) { if (StringUtils.hasText(v)) c.accept(v.trim()); } + private static void setPresent(String v, Consumer c) { if (v != null) c.accept(trim(v)); } + private static String trim(String v) { return v == null || v.isBlank() ? null : v.trim(); } + private static String norm(String v) { return v.trim().toUpperCase(); } + private static String defaultCurrency(String v) { return StringUtils.hasText(v) ? norm(v) : "USD"; } + private static int value(Integer v) { return v == null ? 0 : v; } private static long value(Long v) { return v == null ? 0 : v; } + private static ResponseStatusException badRequest(String m) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, m); } + private static ResponseStatusException conflict(String m) { return new ResponseStatusException(HttpStatus.CONFLICT, m); } + private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "US truck order not found"); } +} diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml new file mode 100644 index 000000000..9a76d798a --- /dev/null +++ b/lastleg-service/src/main/resources/application.yml @@ -0,0 +1,78 @@ +server: + port: ${LASTLEG_PORT:9303} +spring: + sql: + init: + mode: always + encoding: UTF-8 + schema-locations: classpath:db/lastleg.sql + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + application: + name: lastleg-service + config: + import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + cloud: + nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + 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} + 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} +management.endpoints.web.exposure.include: health,info,prometheus + +lastleg: + dispatch-query-api: + enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true} + smart-dispatch-api: + enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} + +baiyue: + http: + clients: + unload-photo-file: + connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s} + relabel-photo-file: + connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} + warehouse-resource: + connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} + pallet-physical: + connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL:} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + file: + url: ${FILE_SERVICE_URL:http://127.0.0.1:9205} + event-bridge: + url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} + 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} + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} + jwt-mode: jwks + session-mode: claims + 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/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql new file mode 100644 index 000000000..44d82c7d3 --- /dev/null +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -0,0 +1,1665 @@ +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误', + PRIMARY KEY(event_id), + UNIQUE KEY uk_ll_loading_inbox_tenant_key (customer_id,event_type,event_id) +) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( + container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', + standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号', + observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', actual_at DATETIME(3) NULL COMMENT '节点实际发生时间', + location_code VARCHAR(128) NULL COMMENT '节点地点编码', location_name VARCHAR(255) NULL COMMENT '节点地点名称', + provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码', provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID', + provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码', source_reference VARCHAR(255) NULL COMMENT '供应商来源引用', + raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256', aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本', + handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本', last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID', + payload_hash VARCHAR(71) 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(container_booking_id), KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status), + KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) +) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; + +CREATE TABLE IF NOT EXISTS ll_order ( + id BIGINT NOT NULL COMMENT '雪花 ID', + order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + root_inbound_id VARCHAR(128) COMMENT '根入仓单 ID', + source_order_id VARCHAR(128) COMMENT '来源订单 ID', + source_order_no VARCHAR(128) COMMENT '来源订单号', + root_order_id VARCHAR(128) COMMENT '根订单 ID', + customer_name VARCHAR(255) COMMENT '货主名称', + source_warehouse_id VARCHAR(128) COMMENT '来源国内装柜仓 ID 快照', + source_warehouse_name VARCHAR(255) COMMENT '来源国内装柜仓名称快照', + overseas_warehouse_id VARCHAR(128) COMMENT '海外实际收货仓 ID,由绑定仓库的PDA操作员认领柜号时冻结', + overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称', + transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID', + transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码', + transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称', + fba_warehouse_code VARCHAR(128) COMMENT 'FBA 仓库编码', + container_no VARCHAR(64) COMMENT '集装箱号', + container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态', + abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', + volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', + product_quantity INT COMMENT '货物数量', + volume_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)', + estimated_arrival_at DATETIME(3) COMMENT '预计到港时间', + actual_arrival_at DATETIME(3) COMMENT '实际到港时间', + estimated_warehouse_at DATETIME(3) COMMENT '预计到仓时间', + inbound_order_no VARCHAR(128) COMMENT '入库单号', + outbound_order_no VARCHAR(128) COMMENT '出库单号', + delivery_order_no VARCHAR(128) COMMENT '派送单号', + relabel_order_no VARCHAR(128) COMMENT '换标单号', + relabel_status VARCHAR(32) COMMENT '换标状态', + pallet_merge_flag VARCHAR(32) COMMENT '合托标记', + current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成', + sub_order_status VARCHAR(255) NULL COMMENT '子单据状态汇总', + private_remark VARCHAR(1000) COMMENT '备注(仅自己)', + customer_remark VARCHAR(1000) COMMENT '备注(客户)', + warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', + source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', + source_loading_plan_version BIGINT COMMENT '来源装柜计划版本', + source_handover_version BIGINT COMMENT '交接版本', + source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_source (customer_id,source_region,source_inbound_id), + KEY idx_ll_order_group(customer_id,order_no), + KEY idx_ll_order_container(customer_id,container_no), + KEY idx_ll_order_list(customer_id,current_status,created_at) +) ENGINE=InnoDB COMMENT='尾程订单主表'; + +CREATE TABLE IF NOT EXISTS ll_order_detail ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', + sku_code VARCHAR(128) COMMENT 'SKU 编码', + product_name_zh VARCHAR(255) COMMENT '中文品名', + product_name_en VARCHAR(255) COMMENT '英文品名', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + product_quantity INT COMMENT '数量', + pallet_count INT COMMENT '托数', + gross_weight_kg DECIMAL(18,4) COMMENT '毛重(千克)', + net_weight_kg DECIMAL(18,4) COMMENT '净重(千克)', + dimensional_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)', + volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) COMMENT '目的仓 ID', + destination_warehouse_code VARCHAR(128) COMMENT '目的仓编码', + destination_warehouse_name VARCHAR(255) COMMENT '目的仓名称', + fba_tracking_no VARCHAR(255) COMMENT 'FBA跟踪号', + shipment_no VARCHAR(255) COMMENT '货件编号', + shipping_mark VARCHAR(255) COMMENT '唛头', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_detail_source(order_id,source_inbound_detail_id), + KEY idx_ll_detail_order(order_id), + KEY idx_ll_detail_customer_order(customer_id,order_id,id) +) ENGINE=InnoDB COMMENT='尾程订单实装商品冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_loading_order_status_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + from_status VARCHAR(32) COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + event_id VARCHAR(128) COMMENT '触发事件 ID', + remark VARCHAR(500) COMMENT '状态变更备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间', + PRIMARY KEY(id), + KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单状态变更历史'; + +CREATE TABLE IF NOT EXISTS ll_event_conflict ( + id BIGINT NOT NULL COMMENT '冲突记录主键', event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', + customer_id BIGINT NOT NULL COMMENT '租户 ID', conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', + conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY(id), KEY idx_ll_conflict_event(event_id,customer_id) +) ENGINE=InnoDB COMMENT='事件冲突记录'; + +CREATE TABLE IF NOT EXISTS ll_event_inbox ( + id BIGINT NOT NULL COMMENT '事件接收记录主键', event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID', + customer_id BIGINT NOT NULL COMMENT '租户 ID', event_type VARCHAR(128) NOT NULL COMMENT '事件类型', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', + status VARCHAR(32) NOT NULL COMMENT '消费状态', error_message VARCHAR(512) NULL COMMENT '错误信息', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbox_event(event_id), KEY idx_ll_inbox_source(customer_id,aggregate_id) +) ENGINE=InnoDB COMMENT='通用事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_inbound_order ( + id BIGINT NOT NULL COMMENT '入仓单主键', customer_id BIGINT NOT NULL COMMENT '客户账号标识', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', + source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', + inbound_code VARCHAR(128) NULL COMMENT '入仓码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', + source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', handover_version BIGINT NOT NULL COMMENT '交接版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', + status VARCHAR(32) NOT NULL COMMENT '入仓单状态', exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_order_source(customer_id,source_region,source_inbound_id), UNIQUE KEY uk_ll_order_event(source_event_id,source_inbound_id) +) ENGINE=InnoDB COMMENT='尾程入仓单快照'; + +CREATE TABLE IF NOT EXISTS ll_inbound_order_item ( + id BIGINT NOT NULL COMMENT '入仓明细主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '入仓单 ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', package_count INT NOT NULL COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', shipment_no VARCHAR(128) NULL COMMENT '货件编号', shipping_mark VARCHAR(255) NULL COMMENT '唛头', + sku_code VARCHAR(128) NULL COMMENT 'SKU 编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', product_name_en VARCHAR(255) NULL COMMENT '英文品名', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_item_order(customer_id,order_id) +) ENGINE=InnoDB COMMENT='尾程入仓商品明细快照'; + +CREATE TABLE IF NOT EXISTS ll_order_status_history ( + id BIGINT NOT NULL COMMENT '订单状态历史主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '订单 ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', reason VARCHAR(255) NULL COMMENT '变更原因', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', PRIMARY KEY(id) +) ENGINE=InnoDB COMMENT='尾程订单状态历史'; + +-- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。 +ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱'; +ALTER TABLE ll_order COMMENT='尾程订单主表'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); +PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); +PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); +PREPARE lastleg_inbound_destination_type_stmt FROM @ddl; EXECUTE lastleg_inbound_destination_type_stmt; DEALLOCATE PREPARE lastleg_inbound_destination_type_stmt; +ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; +ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; +ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; +ALTER TABLE ll_event_inbox COMMENT='通用事件接收幂等箱'; +ALTER TABLE ll_inbound_order COMMENT='尾程入仓单快照'; +ALTER TABLE ll_inbound_order_item COMMENT='尾程入仓商品明细快照'; +ALTER TABLE ll_order_status_history COMMENT='尾程订单状态历史'; + +ALTER TABLE ll_event_conflict + MODIFY COLUMN id BIGINT NOT NULL COMMENT '冲突记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', + MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', + MODIFY COLUMN conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; +ALTER TABLE ll_event_inbox + MODIFY COLUMN id BIGINT NOT NULL COMMENT '事件接收记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', + MODIFY COLUMN event_type VARCHAR(128) NOT NULL COMMENT '事件类型', MODIFY COLUMN aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', MODIFY COLUMN aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID', MODIFY COLUMN aggregate_version BIGINT NOT NULL COMMENT '聚合版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '消费状态', MODIFY COLUMN error_message VARCHAR(512) NULL COMMENT '错误信息', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; +ALTER TABLE ll_inbound_order + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓单主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域', MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '入仓码', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', MODIFY COLUMN source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', MODIFY COLUMN handover_version BIGINT NOT NULL COMMENT '交接版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '入仓单状态', MODIFY COLUMN exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; +ALTER TABLE ll_inbound_order_item + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓明细主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '入仓单ID', MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细ID', MODIFY COLUMN package_count INT NOT NULL COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT '货件编号', MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头', MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU编码', MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名', MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; +ALTER TABLE ll_order_status_history + MODIFY COLUMN id BIGINT NOT NULL COMMENT '订单状态历史主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '订单 ID', MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态', MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', MODIFY COLUMN reason VARCHAR(255) NULL COMMENT '变更原因', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间'; + +ALTER TABLE ll_loading_event_inbox + MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + MODIFY COLUMN event_version INT NOT NULL COMMENT '事件契约版本', + MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + MODIFY COLUMN payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + MODIFY COLUMN received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + MODIFY COLUMN processed_at DATETIME(3) NULL COMMENT '处理完成时间', + MODIFY COLUMN last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误'; + +ALTER TABLE ll_order + MODIFY COLUMN id BIGINT NOT NULL COMMENT '雪花 ID', + MODIFY COLUMN order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', + MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', + MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', + MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', + MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT '货主名称', + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓 ID', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称', + MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码', + MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号', + MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态', + MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + MODIFY COLUMN product_quantity INT NULL COMMENT '货物数量', + MODIFY COLUMN volume_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + MODIFY COLUMN actual_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + MODIFY COLUMN estimated_warehouse_at DATETIME(3) NULL COMMENT '预计到仓时间', + MODIFY COLUMN inbound_order_no VARCHAR(128) NULL COMMENT '入库单号', + MODIFY COLUMN outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + MODIFY COLUMN delivery_order_no VARCHAR(128) NULL COMMENT '派送单号', + MODIFY COLUMN relabel_order_no VARCHAR(128) NULL COMMENT '换标单号', + MODIFY COLUMN relabel_status VARCHAR(32) NULL COMMENT '换标状态', + MODIFY COLUMN pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记', + MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + MODIFY COLUMN private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)', + MODIFY COLUMN customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)', + MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', + MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', + MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', + MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', + MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本', + MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +-- Existing installations: convert the row identity to an assigned Snowflake ID and +-- make order_no a container/loading-plan grouping number rather than a row-unique key. +SET @ll_add_transfer_warehouse_id = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT ''转入目的仓ID'' AFTER overseas_warehouse_name', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_id' +); +PREPARE ll_add_transfer_warehouse_id_stmt FROM @ll_add_transfer_warehouse_id; +EXECUTE ll_add_transfer_warehouse_id_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_id_stmt; + +SET @ll_add_transfer_warehouse_code = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT ''转入目的仓编码'' AFTER transfer_warehouse_id', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_code' +); +PREPARE ll_add_transfer_warehouse_code_stmt FROM @ll_add_transfer_warehouse_code; +EXECUTE ll_add_transfer_warehouse_code_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_code_stmt; + +SET @ll_add_transfer_warehouse_name = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT ''转入目的仓名称'' AFTER transfer_warehouse_code', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_name' +); +PREPARE ll_add_transfer_warehouse_name_stmt FROM @ll_add_transfer_warehouse_name; +EXECUTE ll_add_transfer_warehouse_name_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_name_stmt; + +ALTER TABLE ll_order + MODIFY COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT '转入目的仓ID', + MODIFY COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT '转入目的仓编码', + MODIFY COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT '转入目的仓名称'; + +SET @ll_drop_order_no_unique = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE ll_order DROP INDEX uk_ll_order_no', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND index_name = 'uk_ll_order_no' AND non_unique = 0 +); +PREPARE ll_drop_order_no_unique_stmt FROM @ll_drop_order_no_unique; +EXECUTE ll_drop_order_no_unique_stmt; +DEALLOCATE PREPARE ll_drop_order_no_unique_stmt; + +SET @ll_add_order_group_index = ( + SELECT IF(COUNT(*) = 0, + 'CREATE INDEX idx_ll_order_group ON ll_order(customer_id,order_no)', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND index_name = 'idx_ll_order_group' +); +PREPARE ll_add_order_group_index_stmt FROM @ll_add_order_group_index; +EXECUTE ll_add_order_group_index_stmt; +DEALLOCATE PREPARE ll_add_order_group_index_stmt; + +ALTER TABLE ll_order_detail + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', + MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU 编码', + MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名', + MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名', + MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + MODIFY COLUMN product_quantity INT NULL COMMENT '数量', + MODIFY COLUMN pallet_count INT NULL COMMENT '托数', + MODIFY COLUMN gross_weight_kg DECIMAL(18,4) NULL COMMENT '毛重(千克)', + MODIFY COLUMN net_weight_kg DECIMAL(18,4) NULL COMMENT '净重(千克)', + MODIFY COLUMN dimensional_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + MODIFY COLUMN fba_tracking_no VARCHAR(255) NULL COMMENT 'FBA跟踪号', + MODIFY COLUMN shipment_no VARCHAR(255) NULL COMMENT '货件编号', + MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan ( + id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + overseas_warehouse_id BIGINT NULL COMMENT '海外实际收货共享物理仓ID,由PDA认领柜号时冻结', + status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', + line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + total_volume_cbm DECIMAL(18,4) 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_plan_no (plan_no), + UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id, source_order_id), + KEY idx_ll_inbound_plan_container (customer_id, container_no) +) ENGINE=InnoDB COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( + id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + destination_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_line_order (plan_id, order_id), + KEY idx_ll_inbound_line_plan (plan_id), + KEY idx_ll_inbound_line_customer_plan (customer_id, plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( + id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NULL COMMENT '生成尾程订单时绑定的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', + line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', + warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), + KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), + KEY idx_ll_inbound_carton_order(order_id,status) +) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( + id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', + allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), + UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no), + KEY idx_ll_inbound_pallet_plan (plan_id), + KEY idx_ll_inbound_pallet_customer_plan (customer_id, plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( + id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_allocation (line_id, pallet_id), + KEY idx_ll_inbound_allocation_plan (plan_id), + KEY idx_ll_inbound_allocation_customer_plan (customer_id, plan_id), + KEY idx_ll_inbound_allocation_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; + +-- 修复既有入库计划表的注释;历史“每柜一张计划”记录不在这里猜测拆分。 +ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NOT NULL DEFAULT 0 COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id')); +PREPARE inbound_plan_source_order_stmt FROM @ddl; EXECUTE inbound_plan_source_order_stmt; DEALLOCATE PREPARE inbound_plan_source_order_stmt; +ALTER TABLE ll_inbound_plan + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', + MODIFY COLUMN container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', + MODIFY COLUMN line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + MODIFY COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +ALTER TABLE ll_inbound_plan_line COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; +ALTER TABLE ll_inbound_plan_line + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单ID', + MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + MODIFY COLUMN planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量'; + +ALTER TABLE ll_inbound_plan_pallet COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; +ALTER TABLE ll_inbound_plan_pallet + MODIFY COLUMN id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', + MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + MODIFY COLUMN sequence_no INT NOT NULL COMMENT '计划内托盘序号'; + +ALTER TABLE ll_inbound_plan_pallet_allocation COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; +ALTER TABLE ll_inbound_plan_pallet_allocation + MODIFY COLUMN id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)'; + +-- Additive inbound picking schema. Guarded statements preserve existing rows on restart. +ALTER TABLE ll_inbound_plan MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '业务状态', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(255) NULL COMMENT '操作人姓名', + pushed_at DATETIME(3) NULL COMMENT '任务下发时间', started_at DATETIME(3) NULL COMMENT '开始时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + unload_photo_file_ids JSON NULL COMMENT '卸柜照片文件ID列表', unload_photo_uploaded_at DATETIME(3) NULL COMMENT '卸柜照片上传成功时间', + estimated_empty_return_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功后两天)', + version INT 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_task_plan (customer_id, plan_id), + UNIQUE KEY uk_ll_inbound_pda_task_no (task_no) +) ENGINE=InnoDB COMMENT='入仓PDA作业任务'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; + +CREATE TABLE IF NOT EXISTS ll_event_outbox ( + id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本', + event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', + locked_at DATETIME(3) NULL COMMENT '投递租约获取时间', locked_by VARCHAR(128) NULL COMMENT '投递租约持有者', + last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), + KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at) +) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote ( + id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称', + warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照', + warehouse_name VARCHAR(128) NOT NULL COMMENT '合作仓名称快照', country_code VARCHAR(2) NOT NULL COMMENT '国家二字码快照', + provider_name VARCHAR(128) NOT NULL COMMENT '报价方', currency CHAR(3) NOT NULL COMMENT '币种', + effective_start DATE NOT NULL COMMENT '生效开始日期', effective_end DATE NOT NULL COMMENT '生效结束日期', + version_code VARCHAR(64) NOT NULL COMMENT '业务版本号', status VARCHAR(16) NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_warehouse_provider_version (warehouse_id,provider_name,version_code), + KEY idx_ll_quote_page (status,updated_at,id), KEY idx_ll_quote_effective (warehouse_id,provider_name,currency,effective_start,effective_end) +) ENGINE=InnoDB COMMENT='海外仓报价主表'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_fee ( + id BIGINT NOT NULL COMMENT '费用明细ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + item VARCHAR(128) NOT NULL COMMENT '费用项目', billing_unit VARCHAR(64) NOT NULL COMMENT '计费单位', + unit_price DECIMAL(18,4) NOT NULL COMMENT '单价', minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', + remark VARCHAR(500) NULL COMMENT '备注', PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_fee_sequence (quote_id,sequence_no), + KEY idx_ll_quote_fee_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价仓库费用'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_transit_price ( + id BIGINT NOT NULL COMMENT '中转价格ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + fba_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码', delivery_method VARCHAR(64) NOT NULL COMMENT '报价运输方式', + estimated_miles DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '预估英里数', price DECIMAL(18,4) NOT NULL COMMENT '报价金额', + minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', remark VARCHAR(500) NULL COMMENT '备注', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_transit_sequence (quote_id,sequence_no), + KEY idx_ll_quote_transit_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价中转价格明细'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate ( + id BIGINT NOT NULL COMMENT '月度汇率ID', rate_month CHAR(7) NOT NULL COMMENT '汇率月份,格式yyyy-MM', + base_currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '基准币种', quote_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '报价币种', + usd_to_cny_average DECIMAL(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_exchange_rate_month (rate_month), + KEY idx_ll_exchange_rate_page (status,rate_month) +) ENGINE=InnoDB COMMENT='美元兑人民币月度平均汇率'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history ( + id BIGINT NOT NULL COMMENT '汇率变更历史ID', exchange_rate_id BIGINT NOT NULL COMMENT '月度汇率ID', + before_rate DECIMAL(18,8) NULL COMMENT '变更前汇率', after_rate DECIMAL(18,8) NOT NULL COMMENT '变更后汇率', + before_status VARCHAR(16) NULL COMMENT '变更前状态', after_status VARCHAR(16) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NOT NULL COMMENT '操作人', remark VARCHAR(1000) NULL COMMENT '变更说明', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at) +) ENGINE=InnoDB COMMENT='月度汇率变更历史'; + +CREATE TABLE IF NOT EXISTS ll_vehicle ( + id BIGINT NOT NULL COMMENT '车辆档案ID', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号', + model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升', + mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位', + phone VARCHAR(64) NULL COMMENT '司机或外派单位联系电话', region VARCHAR(16) NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS', + ownership VARCHAR(16) NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED', + status VARCHAR(32) NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', 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) +) ENGINE=InnoDB COMMENT='平台车辆主数据'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''投递租约获取时间'' AFTER published_at')); +PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''投递租约持有者'' AFTER locked_at')); +PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + allocation_id BIGINT NULL COMMENT '分配记录ID', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容JSON', result_json LONGTEXT NOT NULL COMMENT '处理结果JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_operation_key (customer_id, idempotency_key), + KEY idx_ll_inbound_pda_operation_task (task_id), KEY idx_ll_inbound_pda_operation_allocation (allocation_id) +) ENGINE=InnoDB COMMENT='入仓PDA操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + merge_group_no VARCHAR(64) NOT NULL COMMENT '合托分组号', source_pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', target_pallet_id BIGINT NOT NULL COMMENT '目标托盘ID', + source_allocation_id BIGINT NOT NULL COMMENT '来源分配记录ID', target_allocation_id BIGINT NOT NULL COMMENT '目标分配记录ID', + source_line_id BIGINT NOT NULL COMMENT '来源计划明细ID', target_line_id BIGINT NOT NULL COMMENT '目标计划明细ID', + source_order_id BIGINT NOT NULL COMMENT '来源订单ID', target_order_id BIGINT NOT NULL COMMENT '目标订单ID', + moved_package_count INT NOT NULL COMMENT '移动箱数', moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '移动体积(立方米)', + source_pallet_scrapped TINYINT NOT NULL DEFAULT 0 COMMENT '来源托盘是否已报废', status VARCHAR(32) NOT NULL COMMENT '业务状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_inbound_merge_task (customer_id, task_id), KEY idx_ll_inbound_merge_group (merge_group_no) +) ENGINE=InnoDB COMMENT='入仓托盘合托记录'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id IS NULL OR l.customer_id=''; +ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id IS NULL OR v.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id IS NULL OR a.customer_id=''; +ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); +PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; +DROP PROCEDURE IF EXISTS assert_inbound_plan_customer_scope_ready; +DELIMITER $$ +CREATE PROCEDURE assert_inbound_plan_customer_scope_ready() +BEGIN + IF EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inbound plan child customer scope migration is incomplete'; + END IF; +END$$ +DELIMITER ; +CALL assert_inbound_plan_customer_scope_ready(); +DROP PROCEDURE assert_inbound_plan_customer_scope_ready; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); +PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.fba_warehouse_code=o.fba_warehouse_code, + l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(o.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) +WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL) + OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', + operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', + signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间', + completed_at DATETIME(3) 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 '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key), + KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; + +CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( + id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照', + received_packages INT NOT NULL COMMENT 'SKU实收箱数', 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 '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code), + KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id), + KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id) +) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度'; + +CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( + id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( + id BIGINT NOT NULL COMMENT 'PDA作业历史ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识', operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', + operation_type VARCHAR(64) NOT NULL COMMENT '作业类型', business_key VARCHAR(255) NOT NULL COMMENT '作业业务键', + status VARCHAR(32) NOT NULL COMMENT '作业结果状态', idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键', + request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希', summary VARCHAR(1000) NULL COMMENT '作业结果摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at), + KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key) +) ENGINE=InnoDB COMMENT='海外PDA真实作业历史'; + +CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation ( + id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键', + operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执', + last_error VARCHAR(2000) 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(id), UNIQUE KEY uk_ll_pda_pallet_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task ( + id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照', + status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数', + counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数', + created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识', + submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no), + UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key), + KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item ( + id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识', + balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照', + product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照', + book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数', + difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态', + position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识', + version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action ( + id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希', + status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( + id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID', + source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID', + target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数', + position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数', + pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因', + idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希', + status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_inventory_move_no(move_no), + UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key), + KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', + appointment_at DATETIME(3) NOT NULL COMMENT '预约时间', + expected_loading_at DATETIME(3) NOT NULL COMMENT '预计装车时间', + appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', + appointment_remark VARCHAR(1000) NULL COMMENT '预约备注', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + selected_count INT NOT NULL DEFAULT 0 COMMENT '已选明细数量', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_fba_appointment_no (customer_id, appointment_no), + KEY idx_ll_fba_appointment_list (customer_id, appointment_status, fba_warehouse_code, created_at) +) ENGINE=InnoDB COMMENT='FBA入仓预约单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( + id BIGINT NOT NULL COMMENT '主键ID', + appointment_id BIGINT NOT NULL COMMENT '预约单ID', + plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', + order_id BIGINT NOT NULL COMMENT '订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id), + KEY idx_ll_fba_appointment_item_plan (plan_id), + KEY idx_ll_fba_appointment_item_order (order_id) +) ENGINE=InnoDB COMMENT='FBA入仓预约明细'; + +ALTER TABLE ll_loading_order_status_history + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态', + MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + MODIFY COLUMN event_id VARCHAR(128) NULL COMMENT '触发事件 ID', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '状态变更备注', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间'; + +CREATE TABLE IF NOT EXISTS ll_inventory_balance ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外仓编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', + inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', + available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', + allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', + in_transit_packages BIGINT NOT NULL DEFAULT 0 COMMENT '在途箱数', receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + procurement_packages BIGINT NOT NULL DEFAULT 0 COMMENT '采购业务占用箱数', + 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code), + KEY idx_ll_inventory_list (customer_id,overseas_warehouse_id,updated_at), + KEY idx_ll_inventory_inbound (customer_id,inbound_code), + KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at) +) ENGINE=InnoDB COMMENT='客户入仓单与海外目的仓维度实时库存余额'; + +CREATE TABLE IF NOT EXISTS ll_inventory_position ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + pallet_id BIGINT NOT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', + batch_no VARCHAR(128) NOT NULL DEFAULT '' COMMENT '库存批次号', warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', + warehouse_location_code VARCHAR(128) NOT NULL COMMENT '仓库库位编码', + available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', + allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', + receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_position (balance_id,pallet_id,batch_no), + KEY idx_ll_inventory_position_pallet (customer_id,pallet_id), + KEY idx_ll_inventory_position_location (customer_id,overseas_warehouse_id,warehouse_location_code) +) ENGINE=InnoDB COMMENT='托盘库位批次库存明细'; + +CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', position_id BIGINT NULL COMMENT '库存仓位记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', + pallet_id BIGINT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NULL COMMENT '托盘号', batch_no VARCHAR(128) NULL COMMENT '库存批次号', + warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', sku_code VARCHAR(128) NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NULL COMMENT '箱唛', carton_packages BIGINT NULL COMMENT '箱内箱数', + business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + business_order_id BIGINT NULL COMMENT '业务单据ID', business_order_no VARCHAR(128) NULL COMMENT '业务单号', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + source_event_id VARCHAR(128) NULL COMMENT '来源事件ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + status_from VARCHAR(32) NULL COMMENT '操作前状态', status_to VARCHAR(32) NULL COMMENT '操作后状态', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', remark VARCHAR(1000) NULL COMMENT '备注', + occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_ledger_idempotency (customer_id,idempotency_key), + KEY idx_ll_inventory_ledger_query (customer_id,overseas_warehouse_id,occurred_at), + KEY idx_ll_inventory_ledger_inbound (customer_id,inbound_code,occurred_at), + KEY idx_ll_inventory_ledger_business (customer_id,business_type,business_order_no) +) ENGINE=InnoDB COMMENT='不可变库存变动流水'; + +CREATE TABLE IF NOT EXISTS ll_inventory_operation ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING' COMMENT '库存命令处理状态', + result_json LONGTEXT NULL COMMENT '处理结果JSON', last_error VARCHAR(2000) 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 (id), UNIQUE KEY uk_ll_inventory_operation_key (customer_id,idempotency_key), + KEY idx_ll_inventory_operation_status (customer_id,status,updated_at) +) ENGINE=InnoDB COMMENT='库存命令幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + plan_id BIGINT NOT NULL COMMENT '入仓计划ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NOT NULL COMMENT '分配记录ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', + warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', + batch_no VARCHAR(128) NOT NULL COMMENT '库存批次号', received_packages BIGINT NOT NULL COMMENT '实收箱数', + inventory_balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', received_at DATETIME(3) NOT NULL COMMENT '实收时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code), + KEY idx_ll_stock_receipt_plan (customer_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id) +) ENGINE=InnoDB COMMENT='入仓PDA库存实收快照'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_order ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', + driver_name VARCHAR(128) NULL COMMENT '司机姓名', + driver_phone VARCHAR(64) NULL COMMENT '司机电话', + carrier_name VARCHAR(255) NULL COMMENT '承运商名称', + route VARCHAR(255) NULL COMMENT '运输路线', + depart_at DATETIME(3) NULL COMMENT '发车时间', + fba_appointment_no VARCHAR(64) NULL COMMENT 'FBA预约号', + fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', + vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT 'PDA累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', + partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_dispatch_order_no (customer_id,dispatch_no), + KEY idx_ll_dispatch_order_list (customer_id,status,depart_at), + KEY idx_ll_dispatch_order_vehicle (customer_id,vehicle_plate) +) ENGINE=InnoDB COMMENT='尾程派车单'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_item ( + id BIGINT NOT NULL COMMENT '主键ID', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + order_id BIGINT NULL COMMENT '订单ID', + order_no VARCHAR(64) NULL COMMENT '订单号', + order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', + inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', + source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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), + KEY idx_ll_dispatch_item_inbound (customer_id,inbound_code), + KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='派车货物冻结明细'; + + CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', + active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空', + draft_container_id BIGINT NULL COMMENT '智能排车草稿装车托盘ID', + draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识', + operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空', + released_at DATETIME(3) 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 (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), + KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status) + ) ENGINE=InnoDB COMMENT='派车托盘排他锁'; + + CREATE TABLE IF NOT EXISTS ll_dispatch_draft ( + id BIGINT NOT NULL COMMENT '主键ID', draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识', + customer_id VARCHAR(128) NULL COMMENT '客户租户ID', operator_id VARCHAR(128) NULL COMMENT '草稿操作人ID', + plan_ids_json VARCHAR(4000) NULL COMMENT '所选入仓计划ID列表JSON', containers_json LONGTEXT NULL COMMENT '草稿装车布局JSON快照', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态', expires_at DATETIME(3) NULL COMMENT '草稿失效时间', + released_at DATETIME(3) 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(id), UNIQUE KEY uk_ll_dispatch_draft_token(draft_token), KEY idx_ll_dispatch_draft_operator(operator_id,status) + ) ENGINE=InnoDB COMMENT='智能排车草稿'; + + CREATE TABLE IF NOT EXISTS ll_dispatch_draft_container ( + id BIGINT NOT NULL COMMENT '主键ID', draft_id BIGINT NOT NULL COMMENT '智能排车草稿ID', + draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识', sequence_no INT NOT NULL COMMENT '草稿车厢顺序号', + capacity INT NOT NULL COMMENT '草稿车厢托盘容量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), + KEY idx_ll_dispatch_draft_container_token(draft_token) + ) ENGINE=InnoDB COMMENT='智能排车草稿装车托盘'; + + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_container_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_container_id BIGINT NULL COMMENT ''智能排车草稿装车托盘ID'' AFTER dispatch_order_id')); + PREPARE dispatch_draft_container_column_stmt FROM @ddl; EXECUTE dispatch_draft_container_column_stmt; DEALLOCATE PREPARE dispatch_draft_container_column_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_token'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_token VARCHAR(64) NULL COMMENT ''智能排车草稿随机标识'' AFTER dispatch_order_id')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='operator_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN operator_id VARCHAR(128) NULL COMMENT ''草稿锁操作人ID'' AFTER draft_token')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='expires_at'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN expires_at DATETIME(3) NULL COMMENT ''草稿锁失效时间,正式派车锁为空'' AFTER locked_at')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空''')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + warehouse_id VARCHAR(128) NULL COMMENT '仓库ID', + operator_name VARCHAR(128) NULL COMMENT '操作人姓名', pushed_at DATETIME(3) NOT NULL COMMENT '任务下发时间', completed_at DATETIME(3) 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 (id), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (customer_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_no (customer_id,task_no) +) ENGINE=InnoDB COMMENT='派车PDA拣货任务'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + picked_at DATETIME(3) NOT NULL COMMENT '拣货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (customer_id,idempotency_key), + KEY idx_ll_dispatch_pda_pick_item (customer_id,task_id,dispatch_item_id) +) ENGINE=InnoDB COMMENT='派车PDA拣货记录'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NULL COMMENT 'PDA任务ID', + operation_type VARCHAR(32) NOT NULL COMMENT 'PDA操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + result_json LONGTEXT NULL COMMENT '处理结果JSON', status VARCHAR(32) NOT NULL COMMENT 'PDA操作处理状态', + 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_dispatch_pda_operation_key (customer_id,idempotency_key) +) ENGINE=InnoDB COMMENT='派车PDA操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID', + receipt_id BIGINT NOT 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 '创建时间', + 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) +) ENGINE=InnoDB COMMENT='PDA拣货订单归属记录'; + +-- Transfer a dispatch to delivery: one outbound header, many pallet rows, one delivery header. +CREATE TABLE IF NOT EXISTS ll_outbound_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_outbound_no (customer_id,outbound_no), + UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), + KEY idx_ll_outbound_list (customer_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程出库单'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id), + KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) +) ENGINE=InnoDB COMMENT='出库托盘明细'; + +CREATE TABLE IF NOT EXISTS ll_delivery_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '业务状态', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_delivery_no (customer_id,delivery_no), + UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_delivery_outbound (customer_id,outbound_order_id), + KEY idx_ll_delivery_list (customer_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程派送单'; + +-- Customer warehouse transfer: generated tail order plus selected historical order/inbound bindings. +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', + operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式', + estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号', + tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', + need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + allocation_idempotency_key VARCHAR(255) NULL COMMENT '库存分配幂等键', + allocation_request_hash CHAR(64) NULL COMMENT '库存分配请求哈希', + outbound_idempotency_key VARCHAR(255) NULL COMMENT '出库幂等键', + outbound_request_hash CHAR(64) NULL COMMENT '出库请求哈希', + outbound_at DATETIME(3) NULL COMMENT '出库时间', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_no (customer_id,transfer_no), + UNIQUE KEY uk_ll_wh_transfer_key (customer_id,idempotency_key), + UNIQUE KEY uk_ll_wh_transfer_tail_order (lastleg_order_id), + KEY idx_ll_wh_transfer_list (customer_id,status,created_at), KEY idx_ll_wh_transfer_inbound (customer_id,inbound_code) +) ENGINE=InnoDB COMMENT='客户转仓单'; + +-- 兼容已存在的旧版转仓单表:CREATE TABLE IF NOT EXISTS 不会补充新增字段。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键'' AFTER status')); +PREPARE transfer_allocation_key_stmt FROM @ddl; EXECUTE transfer_allocation_key_stmt; DEALLOCATE PREPARE transfer_allocation_key_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_request_hash CHAR(64) NULL COMMENT ''库存分配请求哈希'' AFTER allocation_idempotency_key')); +PREPARE transfer_allocation_hash_stmt FROM @ddl; EXECUTE transfer_allocation_hash_stmt; DEALLOCATE PREPARE transfer_allocation_hash_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_idempotency_key VARCHAR(255) NULL COMMENT ''出库幂等键'' AFTER allocation_request_hash')); +PREPARE transfer_outbound_key_stmt FROM @ddl; EXECUTE transfer_outbound_key_stmt; DEALLOCATE PREPARE transfer_outbound_key_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_request_hash CHAR(64) NULL COMMENT ''出库请求哈希'' AFTER outbound_idempotency_key')); +PREPARE transfer_outbound_hash_stmt FROM @ddl; EXECUTE transfer_outbound_hash_stmt; DEALLOCATE PREPARE transfer_outbound_hash_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash')); +PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_lastleg_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID', source_lastleg_order_no VARCHAR(32) NOT NULL COMMENT '来源尾程订单号', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_wh_transfer_source (transfer_order_id,source_lastleg_order_id), + KEY idx_ll_wh_transfer_source_list (customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='转仓来源历史订单绑定'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', + package_count INT NOT NULL COMMENT '箱数', shipping_mark VARCHAR(255) NULL COMMENT '运输唛头', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_wh_transfer_item_list (customer_id,transfer_order_id), KEY idx_ll_wh_transfer_item_sku (customer_id,sku_code) +) ENGINE=InnoDB COMMENT='转仓货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( + id BIGINT NOT NULL COMMENT '雪花主键', + transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', + inventory_balance_id BIGINT NOT NULL COMMENT '来源库存余额记录ID', + inbound_order_id BIGINT NOT NULL COMMENT '来源入仓单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '来源入仓单号快照', + pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', + package_count INT NOT NULL COMMENT '分配箱数', + status VARCHAR(32) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', + active_position_key VARCHAR(255) NULL COMMENT '生效中的仓位排他键', + allocated_at DATETIME(3) NOT NULL COMMENT '分配时间', + shipped_at DATETIME(3) NULL COMMENT '出库时间', + received_at DATETIME(3) 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(id), + UNIQUE KEY uk_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 COMMENT='转仓单来源库存分配记录'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_receipt ( + id BIGINT NOT NULL COMMENT '雪花主键', + transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', + allocation_id BIGINT NOT NULL COMMENT '库存分配记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', + destination_inventory_balance_id BIGINT NOT NULL COMMENT '目标库存余额记录ID', + destination_inventory_position_id BIGINT NOT NULL COMMENT '目标库存仓位记录ID', + package_count INT NOT NULL COMMENT '收货箱数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '收货幂等键', + request_hash CHAR(64) NOT NULL COMMENT '收货请求哈希', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + received_at DATETIME(3) NOT NULL COMMENT '收货时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_wh_transfer_receipt_source(transfer_order_id,source_inventory_position_id), + UNIQUE KEY uk_ll_wh_transfer_receipt_key(customer_id,idempotency_key), + UNIQUE KEY uk_ll_wh_transfer_receipt_operation(customer_id,inventory_operation_key), + KEY idx_ll_wh_transfer_receipt_list(customer_id,transfer_order_id,received_at) +) ENGINE=InnoDB COMMENT='转仓来源仓位的目标库存收货记录'; + +-- Customer relabel application: header, item snapshots, and immutable process history. +CREATE TABLE IF NOT EXISTS ll_relabel_application ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号', + lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', + customer_order_no VARCHAR(128) NOT NULL COMMENT '客户订单号', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', + remark VARCHAR(1000) NULL COMMENT '备注', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', + total_package_count INT NOT NULL DEFAULT 0 COMMENT '总箱数', + notified_package_count INT NOT NULL DEFAULT 0 COMMENT '已通知箱数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_relabel_application_no (customer_id,relabel_no), + UNIQUE KEY uk_ll_relabel_application_idempotency (customer_id,idempotency_key), + UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id), + KEY idx_ll_relabel_application_list (customer_id,status,created_at), + KEY idx_ll_relabel_application_customer (customer_id,customer_order_no), + KEY idx_ll_relabel_application_warehouse (customer_id,overseas_warehouse_id) +) ENGINE=InnoDB COMMENT='客户换标申请单'; + +CREATE TABLE IF NOT EXISTS ll_relabel_item ( + id BIGINT NOT NULL COMMENT '主键ID', + application_id BIGINT NOT NULL COMMENT '申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', + product_image_file_id VARCHAR(128) NULL COMMENT '产品图片文件ID', + product_image_file_name VARCHAR(255) NULL COMMENT '产品图片文件名', + product_image_url VARCHAR(1000) NULL COMMENT '产品图片地址', + product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', + total_package_count INT NOT NULL COMMENT '总箱数', + notified_package_count INT NOT NULL COMMENT '已通知箱数', + shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号', + tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', + new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', + label_requirement VARCHAR(1000) NULL COMMENT '换标要求', + relabel_file_id VARCHAR(128) NULL COMMENT '换标文件ID', + relabel_file_name VARCHAR(255) NULL COMMENT '换标文件名', + relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_ll_relabel_item_application (customer_id,application_id), + KEY idx_ll_relabel_item_sku (customer_id,sku_code) +) ENGINE=InnoDB COMMENT='换标货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + application_id BIGINT NOT NULL COMMENT '申请单ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_ll_relabel_status_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='换标状态变更历史'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_task ( + id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID', + status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED', + operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照', + reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', + reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照', + expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', + relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数', + skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', + exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数', + completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', + completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注', + review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', + review_remark VARCHAR(1000) NULL COMMENT '最近复核备注', + claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', + submitted_at DATETIME(3) NULL COMMENT '提交复核时间', + reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', + version INT 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_task_application (customer_id,application_id), + UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), + KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at), + KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact ( + id BIGINT NOT NULL COMMENT '逐件换标事实ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + item_id BIGINT NOT NULL COMMENT '换标申请明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标', + new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', + result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', + exception_code VARCHAR(32) NULL COMMENT '异常编码', + reason VARCHAR(1000) NULL COMMENT '跳过或异常原因', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), + KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id), + KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id) +) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation ( + id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识', + action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', + idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key), + KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_history ( + id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + action VARCHAR(32) NOT NULL COMMENT '现场动作类型', + from_status VARCHAR(32) NULL COMMENT '动作前任务状态', + to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + detail_json LONGTEXT NULL COMMENT '动作详情JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id), + KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史'; + +CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', + source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', + inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no), + UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id) +) ENGINE=InnoDB COMMENT='一件代发托盘档案'; + +CREATE TABLE IF NOT EXISTS ll_dropship_carton ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_carton(customer_id,order_id,carton_mark), + KEY idx_ll_dropship_carton_status(customer_id,status), KEY idx_ll_dropship_carton_sku(customer_id,sku_code,status) +) ENGINE=InnoDB COMMENT='一件代发箱唛明细'; + +CREATE TABLE IF NOT EXISTS ll_dropship_sku_inventory ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', package_count BIGINT NOT NULL COMMENT '箱数', product_quantity BIGINT NOT NULL COMMENT '产品数量', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_sku_inventory(profile_id,sku_code), + KEY idx_ll_dropship_sku_order(customer_id,order_id,sku_code) +) ENGINE=InnoDB COMMENT='一件代发SKU库存汇总'; + +CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + business_order_no VARCHAR(128) NULL COMMENT '业务单号', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', remark VARCHAR(1000) NULL COMMENT '备注', + occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_dropship_ledger_key(customer_id,idempotency_key), KEY idx_ll_dropship_ledger_sku(customer_id,sku_code,carton_mark) +) ENGINE=InnoDB COMMENT='一件代发箱唛库存流水'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', + total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', + total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', + review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', + reviewed_at DATETIME(3) 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(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no), + UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key), + KEY idx_ll_cargo_discard_list(customer_id,status,created_at), + KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃申请单'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', + available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), + KEY idx_ll_cargo_discard_item_list(customer_id,application_id), + KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code) +) ENGINE=InnoDB COMMENT='货物废弃SKU明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), + KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), + KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id) +) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃审批历史'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号', + purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称', + product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接', + purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', + currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', + purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', + inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', + domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', + overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', + remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', + review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', + reviewed_at DATETIME(3) NULL COMMENT '审核时间', 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), + UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key), + KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), + KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购订单'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购操作流水'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_no VARCHAR(64) NOT NULL COMMENT '订单号', + transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + origin VARCHAR(255) NOT NULL COMMENT '起运地', + origin_address VARCHAR(500) NULL COMMENT '起运地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', + destination_address VARCHAR(500) NULL COMMENT '目的地址', + container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', + pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', + package_count INT NULL COMMENT '可选箱数,零或空值均有效', + received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', + allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数', + total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', + total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + budget_amount DECIMAL(18,2) NULL COMMENT '预算金额', + currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式', + requested_date DATE NOT NULL COMMENT '要求服务日期', + status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态', + remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no), + UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key), + KEY idx_ll_us_truck_order_list(customer_id,status,created_at), + KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='美国卡车运输订单'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( + id BIGINT NOT NULL COMMENT '托盘记录ID', + order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '业务托盘号', + sequence_no INT NOT NULL COMMENT '订单内托盘序号', + source VARCHAR(32) NOT NULL COMMENT '托盘来源:订单计划或PDA超收', + status VARCHAR(32) NOT NULL COMMENT '托盘状态:待收、已收或超收待审核', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘资源ID', + package_count INT NULL COMMENT '托盘箱数', + received_at DATETIME(3) 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(id), + UNIQUE KEY uk_ll_us_truck_order_pallet_no(customer_id,order_id,pallet_no), + UNIQUE KEY uk_ll_us_truck_order_pallet_seq(customer_id,order_id,sequence_no), + KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status) +) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_us_truck_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单幂等操作账'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', + order_id BIGINT NOT NULL COMMENT '订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', + from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', + remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), + KEY idx_ll_us_truck_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', + order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', + pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', + container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', + total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', + total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', + budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', + contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式', + requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', + remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', + request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_local_looseboard_no(customer_id,order_no), + UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id), + UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key), + KEY idx_ll_local_looseboard_list(customer_id,status,created_at), + KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='本土散板操作流水'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希', + result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key), + KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账'; + +CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', + event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', + payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', + status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + KEY idx_security_inbox_type_time (event_type,received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; + +CREATE TABLE IF NOT EXISTS ll_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', + tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', + payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', + last_event_id VARCHAR(64) 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 COMMENT='海外IAM安全状态与权限版本投影'; + +-- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 +INSERT IGNORE INTO ll_security_projection ( + aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version, + payload_hash,last_event_id,changed_at,updated_at +) VALUES + ('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256), + 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), + ('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), + 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml new file mode 100644 index 000000000..40e76a822 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -0,0 +1,1169 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas Business API + version: 1.0.0 +paths: + /api/pda/v1/last-leg/receiving-containers/candidates: + get: + summary: 查询当前客户待签收柜号 + description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } } + /api/pda/v1/last-leg/receiving-containers/warehouse-profile: + get: + summary: 查询当前PDA账号绑定的海外仓主档 + description: 权限 lastleg:pda:operate;仓库ID只取Token,名称与编码由共享仓库主档实时解析,不接收warehouseId参数。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前绑定仓库中文名称与编码, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWarehouseProfileApiResponse' } } } }, '403': { description: 账号未绑定有效海外仓 } } + /api/pda/v1/last-leg/receiving-containers/{containerNo}: + get: + summary: 查询待签收柜号详情 + description: 权限 lastleg:pda:operate;按柜号唯一解析客户并返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。 + security: [{ bearerAuth: [] }] + parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } } + /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: + post: + summary: 选择当前待签收柜号 + description: 权限 lastleg:pda:operate;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。 + security: [{ bearerAuth: [] }] + parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } + responses: { '200': { description: 当前柜会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 账号或柜号已有未完成会话 } } + /api/pda/v1/last-leg/receiving-containers/current/unload-photos/files: + post: + summary: 上传当前柜签收照片 + description: 权限 lastleg:pda:operate;multipart 文件归属当前 WAIT_SIGN 会话。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } + responses: { '200': { description: 文件标识, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingPhotoApiResponse' } } } }, '409': { description: 当前柜已签收 } } + /api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download: + get: + summary: 下载当前柜签收照片 + description: 权限 lastleg:pda:operate;仅允许下载当前会话已签收角色绑定的照片。 + security: [{ bearerAuth: [] }] + parameters: + - { name: fileId, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,18}$' } } + responses: + '200': + description: 签收照片字节流;Content-Type 透传文件类型,缺失时为 application/octet-stream;Content-Disposition 为 inline;Cache-Control 为 no-store。 + headers: + Content-Disposition: { schema: { type: string }, description: inline 文件名 } + Cache-Control: { schema: { type: string, example: no-store } } + content: + image/jpeg: { schema: { type: string, format: binary } } + image/png: { schema: { type: string, format: binary } } + application/octet-stream: { schema: { type: string, format: binary } } + '403': { description: fileId 未绑定到当前操作员的当前柜会话三类签收照片 } + '404': { description: 当前柜会话或照片不存在 } + /api/pda/v1/last-leg/receiving-containers/current/sign: + post: + summary: 签收当前柜 + description: 权限 lastleg:pda:operate;精确校验车牌、封签、开门三类照片各且仅 1 张及文件归属后由 WAIT_SIGN 推进 SIGNED;重拍由调用端在签收前替换对应槽位。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSign' } } } } + responses: { '200': { description: 已签收会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 状态或照片校验失败 } } + /api/pda/v1/last-leg/receiving-containers/current: + get: + summary: 恢复当前账号已签柜 + description: 权限 lastleg:pda:operate;返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜,不依赖 planNo。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前柜完整进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '404': { description: 无当前柜 } } + /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve: + post: + summary: 解析当前柜箱唛 + description: 权限 lastleg:pda:operate;FBA 从完整 cartonMark 的第一个 # 前提取固定入仓码,并精确匹配当前锁定柜唯一 FBA line;不要求逐箱 manifest,不校验 SKU 或箱序号。OVERSEAS 必须命中真实 manifest。两者均不返回 SKU,也不登记收货。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } } + responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '404': { description: 入仓码未命中当前柜,或 OVERSEAS 箱码未命中真实 manifest }, '409': { description: 缺少 #、空入仓码前缀、跨柜或当前柜多重命中 } } + /api/pda/v1/last-leg/receiving-containers/current/pallets/resolve: + post: + summary: 解析当前柜实物托盘码 + description: 权限 lastleg:pda:operate;托盘固定从 Warehouse Overseas 公共资源作用域解析并校验实际仓与目的仓。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { type: object, required: [palletCode, inboundCode], properties: { palletCode: { type: string }, inboundCode: { type: string } } } } } } + responses: { '200': { description: 可用托盘与目的匹配结果, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingPalletResolvedApiResponse' } } } }, '409': { description: 仓库、目的仓或托盘状态不符 } } + /api/pda/v1/last-leg/receiving-containers/current/receipts/bulk: + post: + summary: FBA 入仓码按箱数收货 + description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;服务端按当前入仓码所属计划的 line.id ASC 和冻结 SKU 明细顺序拆分箱数并记录真实 SKU 收货;不接收 SKU、customerId、planNo、allocationId、warehouseId、locationId。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } } + responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 海外仓批量、超收或托盘不匹配 } } + /api/pda/v1/last-leg/receiving-containers/current/receipts/carton: + post: + summary: 海外仓入仓码逐箱收货 + description: 权限 lastleg:pda:operate;只允许 destinationType=OVERSEAS,拒绝跨柜、重复箱和不匹配托盘。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } } + responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } } + /api/pda/v1/last-leg/receiving-containers/current/complete: + post: + summary: 完成当前柜收货并入库存 + description: 权限 lastleg:pda:operate;仅柜内每个入仓码实收总数精确相等后完成,库存、计划、尾程状态与卸柜 Outbox 同事务落地。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } + responses: { '200': { description: COMPLETED 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 数量未核平或状态冲突 } } + /api/pda/v1/last-leg/inventory/resolve: + post: + summary: PDA按SKU、箱唛或入仓码扫码查询库存 + description: 仅 WAREHOUSE_PICKER + lastleg:pda:operate;海外实际仓只取Token overseasWarehouseId,不接收客户或仓库参数。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } } + responses: { '200': { description: 同仓跨客户库存与物理仓位事实, content: { application/json: { schema: { $ref: '#/components/schemas/PdaInventoryScanApiResponse' } } } }, '404': { description: A0404:当前绑定仓无匹配库存 } } + /api/pda/v1/last-leg/locations/resolve: + post: + summary: PDA查询库位容量、状态与库存占用 + description: 库位从Warehouse Overseas公共资源解析,并强制匹配Token绑定仓。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } } + responses: { '200': { description: 库位物理事实及Lastleg库存占用汇总, content: { application/json: { schema: { $ref: '#/components/schemas/PdaLocationApiResponse' } } } }, '404': { description: A0404:库位不存在或跨仓 } } + /api/pda/v1/last-leg/pallets/resolve: + post: + summary: PDA查询托盘、库位与绑定库存 + description: 托盘从Warehouse Overseas公共资源解析;跨Token绑定仓返回A0409。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } } + responses: { '200': { description: 托盘物理事实及Lastleg绑定库存, content: { application/json: { schema: { $ref: '#/components/schemas/PdaPalletApiResponse' } } } }, '409': { description: A0409:托盘属于其他仓 } } + /api/pda/v1/last-leg/operations: + get: + summary: 查询当前PDA账号真实作业历史 + description: 返回Token绑定仓内当前操作账号的柜号作业及盘点、移库、托盘、换标历史;不含出库或派送。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: operationType, in: query, schema: { type: string } } + responses: { '200': { description: 真实作业历史分页, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOperationPageApiResponse' } } } } } + /api/platform/v1/last-leg/orders: + get: + summary: 分页查询尾程订单 + description: 权限 platform:overseas-warehouse:order:list;平台管理员省略 customerId 时查询全部客户。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: orderNo, in: query, schema: { type: string } } + - { name: orderType, in: query, schema: { type: string, enum: [FIRST_LEG, RELABEL_APPLICATION, WAREHOUSE_TRANSFER, LOCAL_LOOSEBOARD, RETURN_RELABEL, ONE_CLICK_DROPSHIP] } } + - { name: currentStatus, in: query, schema: { type: string } } + - { name: inboundCode, in: query, schema: { type: string } } + - { name: sourceOrderNo, in: query, schema: { type: string } } + - { name: containerNo, in: query, schema: { type: string } } + - { name: warehouse, in: query, schema: { type: string } } + - { name: transferWarehouseCode, in: query, schema: { type: string } } + - { name: abnormal, in: query, schema: { type: boolean } } + - { name: detained, in: query, schema: { type: boolean } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } } + /api/platform/v1/last-leg/dispatches: + get: + summary: 分页查询排车单 + description: 权限 platform:overseas-warehouse:inbound-order:list;平台跨客户分页查询,前端不传 customerId。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: dispatchNo, in: query, schema: { type: string } } + - { name: vehiclePlate, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + - { name: fbaAppointmentNo, in: query, schema: { type: string } } + responses: + '200': { description: 统一 ApiResponse 包装的排车单分页,records 元素为 DispatchResponse } + /api/platform/v1/last-leg/dispatches/{dispatchNo}: + get: + summary: 查询排车单详情 + description: 权限 platform:overseas-warehouse:inbound-order:list;平台按全局排车单号定位记录,并使用记录自身客户范围查询明细,前端不传 customerId。 + security: [{ bearerAuth: [] }] + parameters: + - { name: dispatchNo, in: path, required: true, schema: { type: string } } + responses: + '200': { description: 统一 ApiResponse 包装的 DispatchResponse } + '404': { description: 排车单不存在 } + '409': { description: 排车单号在不同客户下重复,无法唯一定位 } + /api/platform/v1/last-leg/dispatches/candidates: + get: + summary: 查询智能排车候选托盘 + description: 权限 platform:overseas-warehouse:inbound-order:list;planIds 为空时平台跨客户查询全部可排托盘,传入时按所选入库计划解析客户范围;仅返回尚未被有效排车锁定的计划或已实收托盘,前端不传 customerId。 + security: [{ bearerAuth: [] }] + parameters: + - name: planIds + in: query + required: false + description: 可选的入库计划 ID,逗号分隔;省略时平台查询全部客户候选。 + schema: { type: array, minItems: 1, maxItems: 500, items: { type: integer, format: int64 } } + style: form + explode: false + - { name: orderNo, in: query, schema: { type: string } } + - { name: fbaWarehouseCode, in: query, schema: { type: string } } + - { name: sourceStage, in: query, schema: { type: string, enum: [PLAN, RECEIVED] } } + - { name: draftToken, in: query, schema: { type: string, maxLength: 64 }, description: 当前智能排车草稿标识;当前草稿已锁托盘仍可见 } + responses: + '200': + description: 统一 ApiResponse 包装的候选托盘数组 + content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/SmartDispatchCandidate' } } } } + /api/platform/v1/last-leg/dispatches/pre-arrange: + post: + summary: 创建智能预排车单 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchPreArrange' } } } + responses: + '200': { description: 统一 ApiResponse 包装的预排车详情, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchResponse' } } } } + /api/platform/v1/last-leg/dispatches/draft-locks/lock: + post: + summary: 将托盘移入智能排车草稿并立即锁定 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;草稿锁 30 分钟失效,与正式排车锁共享托盘排他约束。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftPallet' } } } } + responses: { '200': { description: 锁定成功 }, '409': { description: 托盘已被其他草稿或正式排车锁定 } } + /api/platform/v1/last-leg/dispatches/draft-locks/unlock: + post: + summary: 从智能排车草稿移除托盘并立即解锁 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftPallet' } } } } + responses: { '200': { description: 解锁成功 }, '409': { description: 草稿锁不属于当前操作人 } } + /api/platform/v1/last-leg/dispatches/draft-locks/release: + post: + summary: 关闭智能排车草稿并释放草稿内全部托盘 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftRelease' } } } } + responses: { '200': { description: 草稿锁已全部释放 } } + /api/platform/v1/last-leg/dispatches/drafts: + get: + summary: 查询当前操作人的有效智能排车草稿 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;只返回当前操作人未失效且未释放的草稿。 + security: [{ bearerAuth: [] }] + responses: + '200': { description: 统一 ApiResponse 包装的智能排车草稿数组, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/SmartDispatchDraft' } } } } } + /api/platform/v1/last-leg/dispatches/drafts/snapshot: + post: + summary: 保存智能排车草稿布局快照 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;草稿仅归当前操作人。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftSnapshot' } } } } + responses: { '200': { description: 草稿快照保存成功 } } + /api/platform/v1/last-leg/dispatches/drafts/containers: + post: + summary: 在智能排车草稿中新增车厢 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;返回服务端生成的字符串 ID。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftContainerCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的草稿车厢, content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchDraftContainer' } } } } } + /api/platform/v1/last-leg/dispatches/drafts/{draftToken}/containers/{containerId}: + delete: + summary: 删除智能排车草稿中的空车厢 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;仅允许当前操作人删除未绑定托盘的草稿车厢。 + security: [{ bearerAuth: [] }] + parameters: + - { name: draftToken, in: path, required: true, schema: { type: string, maxLength: 64 } } + - { name: containerId, in: path, required: true, schema: { type: string } } + responses: { '200': { description: 空车厢删除成功 }, '409': { description: 车厢仍绑定托盘或草稿状态冲突 } } + /api/platform/v1/last-leg/overseas-procurements: + get: + summary: 分页查询海外采购订单 + description: 权限 platform:overseas-business:purchase:list;customerId 为空时查询全部客户。 + security: [{ bearerAuth: [] }] + parameters: + - { name: customerId, in: query, schema: { type: string } } + - { name: page, in: query, required: true, schema: { type: integer, minimum: 1 } } + - { name: size, in: query, required: true, schema: { type: integer, minimum: 1, maximum: 200 } } + - { name: orderNo, in: query, schema: { type: string } } + - { name: customerKeyword, in: query, schema: { type: string } } + - { name: purchaseCategory, in: query, schema: { type: string } } + - { name: productName, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ProcurementStatus' } } + - { name: purchaseWarehouseId, in: query, schema: { type: string } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单分页 } } + /api/platform/v1/last-leg/overseas-procurements/customer-options: + get: + summary: 查询已有采购订单的客户候选 + description: 权限 platform:overseas-business:purchase:list;候选来自尾程库采购订单客户快照,不跨库查询 IAM。 + security: [{ bearerAuth: [] }] + parameters: + - { name: keyword, in: query, schema: { type: string } } + - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } } + responses: + '200': + description: 客户候选 + content: { application/json: { schema: { $ref: '#/components/schemas/CustomerOptionResponse' } } } + /api/platform/v1/last-leg/overseas-procurements/warehouse-options: + get: + summary: 查询启用的海外合作采购仓候选 + description: 权限 platform:overseas-business:purchase:list;复用服务端缓存目录,浏览器不访问国内内部 API。 + security: [{ bearerAuth: [] }] + responses: + '200': + description: 采购仓候选 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseOptionResponse' } } } + /api/platform/v1/last-leg/overseas-procurements/{orderNo}: + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + get: + summary: 查询海外采购订单详情及操作流水 + description: 权限 platform:overseas-business:purchase:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + patch: + summary: 编辑海外采购订单 + description: 权限 platform:overseas-business:purchase:edit;可选字段缺失表示不修改,显式 null 或空串表示清空。采购仓名称由 warehouse ID 对应候选快照决定。采购出库不属于本 API。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementUpdate' } } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 }, '409': { description: 乐观锁冲突或状态不允许 } } + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/review: + post: + summary: 审核海外采购订单 + description: 权限 platform:overseas-business:purchase:review;同幂等键同请求重放成功,异请求、旧 version 或终态反向审核返回 A0409。 + security: [{ bearerAuth: [] }] + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + /api/merchant/v1/last-leg/overseas-procurements: + post: + summary: 客户创建海外采购订单 + description: 客户 ID 只取认证身份;新单固定进入 PENDING_REVIEW。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + get: + summary: 客户分页查询自己的海外采购订单 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的采购订单分页 } } + /api/merchant/v1/last-leg/overseas-procurements/{orderNo}: + get: + summary: 客户查询自己的海外采购订单详情及流水 + security: [{ bearerAuth: [] }] + parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + /api/platform/v1/last-leg/us-truck-orders: + get: + summary: 分页查询本土卡车订单 + description: 权限 platform:overseas-business:truck:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单分页 } } + post: + summary: 平台为指定客户创建本土卡车订单 + description: 权限 platform:overseas-business:truck:create;customerId 必填且只作为可信服务端隔离键。 + security: [{ bearerAuth: [] }] + parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}: + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + get: + summary: 查询本土卡车订单详情 + description: 权限 platform:overseas-business:truck:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + patch: + summary: 平台普通编辑本土卡车订单 + description: 权限 platform:overseas-business:truck:edit;仅待审核订单可编辑,不能修改状态。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/status: + patch: + summary: 取消未发车的本土卡车订单 + description: 权限 platform:overseas-business:truck:status;该通用入口只接受 CANCELLED,正向状态必须使用具名命令,不能绕过实收、超托与分配事实。 + security: [{ bearerAuth: [] }] + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCancel' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 非法状态迁移 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/review: + parameters: &truckOrderTarget + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + post: + summary: 审核本土卡车订单 + description: 权限 platform:overseas-business:truck:status;PENDING_REVIEW 审核通过进入 WAITING_INBOUND 并生成预期托盘;拒绝进入 REJECTED。幂等键异请求、旧 version 或非法跳转返回 A0409。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReview' } } } } + responses: { '200': { description: 卡车订单详情 }, '409': { description: 版本、状态或幂等冲突 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/receive-pallets: + post: + summary: 记录本土卡车订单实收托盘 + description: 权限 platform:overseas-business:truck:status;幂等记录实收,不创建海外仓出库或派送单。 + security: [{ bearerAuth: [] }] + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReceivePallets' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/review-pallet-overage: + parameters: *truckOrderTarget + post: + summary: 审核实收超出申报数的托盘 + description: 权限 platform:overseas-business:truck:status;APPROVE 将超收托盘纳入本单并进入 WAITING_CONSOLIDATION;REJECT 标记超收托盘拒收并按原申报数继续。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReviewPalletOverage' } } } } + responses: { '200': { description: 卡车订单详情 }, '409': { description: 版本、状态或幂等冲突 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/allocate-pallets: + parameters: *truckOrderTarget + post: + summary: 分配已实收托盘到本土卡车行程 + description: 权限 platform:overseas-business:truck:status;仅接收本单 RECEIVED 托盘 ID;部分分配进入 PARTIALLY_ALLOCATED,全部分配进入 ALLOCATED。不创建海外仓出库单或扣减库存。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckAllocatePallets' } } } } + responses: { '200': { description: 卡车订单详情 }, '409': { description: 托盘归属、状态、版本或幂等冲突 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/prepare-dispatch: + parameters: *truckOrderTarget + post: + summary: 全部托盘分配后准备派车 + description: 权限 platform:overseas-business:truck:status;旧 version、幂等键异请求或非 ALLOCATED 状态返回 A0409。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } } + responses: { '200': { description: 状态 PENDING_DISPATCH } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/dispatch: + parameters: *truckOrderTarget + post: + summary: 确认本土卡车已派车 + description: 权限 platform:overseas-business:truck:status;本土卡车单自身履约,不创建海外仓排车单、出库单或派送单。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } } + responses: { '200': { description: 状态 DISPATCHED } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/start-transit: + parameters: *truckOrderTarget + post: + summary: 确认本土卡车开始运输 + description: 权限 platform:overseas-business:truck:status;仅 DISPATCHED 可进入 IN_TRANSIT。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } } + responses: { '200': { description: 状态 IN_TRANSIT } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/complete: + parameters: *truckOrderTarget + post: + summary: 完成本土卡车订单 + description: 权限 platform:overseas-business:truck:status;仅 IN_TRANSIT 可进入不可逆 COMPLETED。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } } + responses: { '200': { description: 状态 COMPLETED } } + /api/platform/v1/last-leg/local-looseboard-orders: + get: + summary: 分页查询本土散板订单 + description: 权限 platform:overseas-business:looseboard:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的散板订单分页 } } + post: + summary: 创建本土散板订单 + description: 权限 platform:overseas-business:looseboard:create;独立稳定子域,不复用卡车端点。 + security: [{ bearerAuth: [] }] + parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 } } + /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}: + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + get: + summary: 查询本土散板订单详情及操作流水 + description: 权限 platform:overseas-business:looseboard:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 } } + put: + summary: 全量更新待接单的本土散板订单 + description: 权限 platform:overseas-business:looseboard:edit;可空字段传 null 或空串即清空;状态由独立端点推进。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 版本或状态冲突 } } + /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/status: + patch: + summary: 推进或取消本土散板订单 + description: 权限 platform:overseas-business:looseboard:status;只允许 PENDING_ACCEPTANCE→DISPATCHED→IN_TRANSIT→COMPLETED,另建订单可进入 CANCELLED;幂等键异请求、旧 version、非法跳转或终态反向返回 A0409。 + security: [{ bearerAuth: [] }] + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardStatusChange' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 非法状态迁移 } } + /api/platform/v1/last-leg/relabel-applications: + get: + summary: 分页查询海外换标单 + description: 权限 platform:overseas-warehouse:relabel-order:list;平台查询全部换标单,不接收 customerId。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: relabelNo, in: query, schema: { type: string, maxLength: 64 } } + - { name: customerOrderNo, in: query, schema: { type: string, maxLength: 128 } } + - { name: skuCode, in: query, schema: { type: string, maxLength: 128 } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/RelabelStatus' } } + - { name: overseasWarehouseId, in: query, schema: { type: string, maxLength: 128 } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: { '200': { description: 统一 ApiResponse 包装的换标单分页 } } + post: + summary: 创建海外换标单 + description: 权限 platform:overseas-warehouse:relabel-order:create;明细与换标文件按请求快照保存,使用 idempotencyKey 幂等。 + security: [{ bearerAuth: [] }] + parameters: + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 幂等键冲突 } } + /api/platform/v1/last-leg/relabel-applications/{relabelNo}: + get: + summary: 查询海外换标单详情 + description: 权限 platform:overseas-warehouse:relabel-order:list。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } + responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '404': { description: 换标单不存在或不属于当前客户 } } + /api/platform/v1/last-leg/relabel-applications/{relabelNo}/status: + put: + summary: 变更海外换标单状态 + description: 权限 platform:overseas-warehouse:relabel-order:status;状态按 PENDING_PROCESSING→PROCESSING→COMPLETED/REJECTED/CANCELLED 流转。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelStatusChange' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } +components: + schemas: + PdaWarehouseScan: + type: object + required: [code] + properties: { code: { type: string, minLength: 1, maxLength: 128 } } + PdaInventoryItem: + type: object + required: [customerId, balanceId, positionId, inboundCode, palletId, palletCode, availablePackages, holdPackages, receivingPackages, status, version] + properties: + customerId: { type: string } + customerName: { type: string, nullable: true } + balanceId: { type: integer, format: int64 } + positionId: { type: integer, format: int64 } + skuCode: { type: string, nullable: true } + productName: { type: string, nullable: true } + inboundCode: { type: string } + cartonMark: { type: string, nullable: true } + palletId: { type: integer, format: int64 } + palletCode: { type: string } + locationId: { type: integer, format: int64, nullable: true } + locationCode: { type: string, nullable: true } + availablePackages: { type: integer, format: int64, minimum: 0 } + holdPackages: { type: integer, format: int64, minimum: 0 } + receivingPackages: { type: integer, format: int64, minimum: 0 } + status: { type: string } + version: { type: integer, format: int64, minimum: 0 } + PdaInventoryScan: + type: object + required: [queryCode, items] + properties: + queryCode: { type: string } + items: { type: array, items: { $ref: '#/components/schemas/PdaInventoryItem' } } + PdaLocation: + type: object + required: [locationId, locationCode, locationType, status, occupiedPackages, inventoryPositionCount] + properties: + locationId: { type: integer, format: int64 } + locationCode: { type: string } + locationType: { type: string } + maxLoadKg: { type: number, nullable: true } + status: { type: string } + occupiedPackages: { type: integer, format: int64, minimum: 0 } + inventoryPositionCount: { type: integer, minimum: 0 } + PdaPallet: + type: object + required: [palletId, palletCode, status, version, locationId, locationCode, occupiedPackages, inventoryPositionCount, inventory] + properties: + palletId: { type: integer, format: int64 } + palletCode: { type: string } + status: { type: string } + version: { type: integer, minimum: 0 } + locationId: { type: integer, format: int64 } + locationCode: { type: string } + occupiedPackages: { type: integer, format: int64, minimum: 0 } + inventoryPositionCount: { type: integer, minimum: 0 } + inventory: { type: array, items: { $ref: '#/components/schemas/PdaInventoryItem' } } + PdaOperation: + type: object + required: [operationId, operationType, businessKey, status, occurredAt] + properties: + operationId: { type: integer, format: int64 } + operationType: { type: string } + businessKey: { type: string } + status: { type: string } + summary: { type: string, nullable: true } + customerId: { type: string, nullable: true } + occurredAt: { type: string, format: date-time } + PdaInventoryScanApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/PdaInventoryScan' } } + PdaLocationApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/PdaLocation' } } + PdaPalletApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/PdaPallet' } } + PdaOperationPageApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: { type: array, items: { $ref: '#/components/schemas/PdaOperation' } } + page: { type: integer } + size: { type: integer } + total: { type: integer, format: int64 } + pages: { type: integer, format: int64 } + ReceivingAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + ReceivingSign: + type: object + required: [photos, idempotencyKey] + properties: + photos: { type: array, minItems: 3, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedPhoto' } } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingSignedPhoto: + type: object + required: [role, fileId] + properties: + role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] } + fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string } + ReceivingSignedSessionPhoto: + type: object + required: [role, fileId, downloadPath] + properties: + role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] } + fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string } + downloadPath: { type: string, description: 用于会话恢复时直接下载签收照片 } + ReceivingBulk: + type: object + required: [inboundCode, palletCode, packageCount, idempotencyKey] + properties: + inboundCode: { type: string, minLength: 1 } + palletCode: { type: string, minLength: 1 } + packageCount: { type: integer, minimum: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingCarton: + type: object + required: [cartonMark, palletCode, idempotencyKey] + properties: + cartonMark: { type: string, minLength: 1 } + palletCode: { type: string, minLength: 1 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + ReceivingApiResponseBase: + type: object + required: [code, message, timestamp] + properties: + code: { type: string, example: '200' } + message: { type: string } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + timestamp: { type: string, format: date-time } + ReceivingInbound: + type: object + required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages] + properties: + inboundCode: { type: string } + destinationType: { type: string, enum: [FBA, OVERSEAS] } + overseasWarehouseId: { type: string, nullable: true, description: 实际海外收货仓ID;与FBA或其他业务目的仓ID严格区分 } + overseasWarehouseName: { type: string, nullable: true, description: 实际海外收货仓名称快照 } + destinationWarehouseId: { type: string, nullable: true } + destinationWarehouseCode: { type: string, nullable: true } + destinationWarehouseName: { type: string, nullable: true } + fbaWarehouseCode: { type: string, nullable: true } + plannedPalletCount: { type: integer } + totalPackages: { type: integer } + receivedPackages: { type: integer } + pendingPackages: { type: integer } + ReceivingWarehouseProfile: + type: object + required: [warehouseId, warehouseCode, warehouseName] + properties: + warehouseId: { type: string, pattern: '^\d+$' } + warehouseCode: { type: string } + warehouseName: { type: string, description: 国内Warehouse Service主档维护的当前中文名称 } + ReceivingCandidate: + type: object + required: [containerNo, status, planCount, inboundCodeCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, currentAccount] + properties: + containerNo: { type: string } + status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] } + planCount: { type: integer } + inboundCodeCount: { type: integer } + destinationCodes: { type: array, uniqueItems: true, items: { type: string } } + plannedPalletCount: { type: integer } + totalPackages: { type: integer } + receivedPackages: { type: integer } + currentAccount: { type: boolean } + ReceivingSession: + type: object + required: [containerNo, overseasWarehouseId, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds] + properties: + containerNo: { type: string } + overseasWarehouseId: { type: string, pattern: '^\d+$', description: PDA Token绑定并在认领柜号时冻结的共享实际海外收货仓ID } + overseasWarehouseName: { type: string, description: Warehouse Overseas服务校验后的共享实际海外收货仓名称快照 } + status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] } + operatorName: { type: string, nullable: true } + version: { type: integer } + photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedSessionPhoto' } } + preparedAt: { type: string, format: date-time, nullable: true } + signedAt: { type: string, format: date-time, nullable: true } + receivingAt: { type: string, format: date-time, nullable: true } + completedAt: { type: string, format: date-time, nullable: true } + totalPackages: { type: integer } + receivedPackages: { type: integer } + pendingPackages: { type: integer } + inbounds: { type: array, items: { $ref: '#/components/schemas/ReceivingInbound' } } + ReceivingPhoto: + type: object + required: [fileId, fileName, contentType, sizeBytes] + properties: + fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string } + fileName: { type: string } + contentType: { type: string } + sizeBytes: { type: integer, format: int64 } + ReceivingCartonResolved: + allOf: + - { $ref: '#/components/schemas/ReceivingInbound' } + - type: object + required: [cartonMark] + properties: + cartonMark: { type: string } + ReceivingPalletResolved: + type: object + required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible] + properties: + palletCode: { type: string } + warehouseId: { type: integer, format: int64 } + status: { type: string } + fbaWarehouseCode: { type: string, nullable: true } + inboundCode: { type: string } + destinationType: { type: string, enum: [FBA, OVERSEAS] } + compatible: { type: boolean } + ReceivingReceipt: + type: object + required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus] + properties: + result: { type: string } + containerNo: { type: string } + inboundCode: { type: string } + destinationType: { type: string, enum: [FBA, OVERSEAS] } + palletCode: { type: string } + acceptedPackages: { type: integer } + receivedPackages: { type: integer } + pendingPackages: { type: integer } + sessionStatus: { type: string, enum: [SIGNED, RECEIVING] } + ReceivingCandidateListApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { type: array, items: { $ref: '#/components/schemas/ReceivingCandidate' } } } + ReceivingWarehouseProfileApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingWarehouseProfile' } } + ReceivingSessionApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingSession' } } + ReceivingPhotoApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingPhoto' } } + ReceivingCartonResolvedApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingCartonResolved' } } + ReceivingPalletResolvedApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingPalletResolved' } } + ReceivingReceiptApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingReceipt' } } + SmartDispatchCandidate: + type: object + properties: + planId: { type: string } + planPalletId: { type: string } + warehousePalletId: { type: string, nullable: true } + orderId: { type: string } + customerId: { type: string } + customerName: { type: string, nullable: true } + planNo: { type: string, nullable: true } + orderType: { type: string, nullable: true } + volumeWeightKg: { type: number, nullable: true } + containerNo: { type: string, nullable: true } + appointmentAt: { type: string, format: date-time, nullable: true } + expectedLoadingAt: { type: string, format: date-time, nullable: true } + overseasWarehouseId: { type: string } + overseasWarehouseName: { type: string } + orderNo: { type: string } + orderRefs: { type: string } + inboundCode: { type: string } + palletNo: { type: string } + destinationWarehouseId: { type: string } + destinationWarehouseCode: { type: string } + destinationWarehouseName: { type: string } + fbaWarehouseCode: { type: string } + packageCount: { type: integer } + volumeCbm: { type: number } + sourceStage: { type: string, enum: [PLAN, RECEIVED] } + cartonMarks: { type: string, nullable: true, description: 该托盘箱号,逗号分隔 } + SmartDispatchPreArrange: + type: object + required: [planPalletIds] + properties: + planPalletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } } + route: { type: string, nullable: true, maxLength: 255 } + fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } + fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } + vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } + draftToken: { type: string, nullable: true, maxLength: 64, description: 拖入托盘时使用的智能排车草稿标识 } + SmartDispatchDraftPallet: + type: object + required: [planPalletId, draftToken] + properties: + planPalletId: { type: string } + draftToken: { type: string, minLength: 1, maxLength: 64 } + draftContainerId: { type: string, nullable: true, description: 托盘所在草稿车厢ID } + SmartDispatchDraftRelease: + type: object + required: [draftToken] + properties: + draftToken: { type: string, minLength: 1, maxLength: 64 } + SmartDispatchDraftSnapshot: + type: object + required: [draftToken, planIds, containersJson] + properties: + draftToken: { type: string, minLength: 1, maxLength: 64 } + planIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } } + containersJson: { type: string, minLength: 1, maxLength: 20000 } + SmartDispatchDraftContainerCreate: + type: object + required: [draftToken, capacity] + properties: + draftToken: { type: string, minLength: 1, maxLength: 64 } + capacity: { type: integer, minimum: 1, maximum: 500 } + SmartDispatchDraft: + type: object + required: [draftToken, containers] + properties: + draftToken: { type: string } + planIdsJson: { type: string, nullable: true } + containersJson: { type: string, nullable: true } + containers: { type: array, items: { $ref: '#/components/schemas/SmartDispatchDraftContainer' } } + SmartDispatchDraftContainer: + type: object + required: [id, capacity, palletIds] + properties: + id: { type: string } + capacity: { type: integer } + palletIds: { type: array, items: { type: string } } + DispatchResponse: + type: object + properties: + id: { type: string } + dispatchNo: { type: string } + vehiclePlate: { type: string, nullable: true } + driverName: { type: string, nullable: true } + driverPhone: { type: string, nullable: true } + carrierName: { type: string, nullable: true } + orderNos: { type: string } + route: { type: string, nullable: true } + overseasWarehouseId: { type: string } + overseasWarehouseName: { type: string } + departAt: { type: string, format: date-time, nullable: true } + fbaAppointmentNo: { type: string, nullable: true } + fbaAppointmentStatus: { type: string } + vehicleAppointmentStatus: { type: string } + estimatedArrivalAt: { type: string, format: date-time, nullable: true } + status: { type: string, description: 预排新建固定为 PENDING_DEPARTURE;查询可返回历史排车状态 } + itemCount: { type: integer } + palletCount: { type: integer } + packageCount: { type: integer } + pickedPackageCount: { type: integer } + shippedPackageCount: { type: integer } + remainingPackageCount: { type: integer } + partialCompletionReason: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } } + DispatchItem: + type: object + properties: + id: { type: string } + planPalletId: { type: string } + warehousePalletId: { type: string, nullable: true } + palletNo: { type: string } + overseasWarehouseId: { type: string } + overseasWarehouseName: { type: string } + orderNo: { type: string } + orderRefs: { type: string } + inboundCode: { type: string } + destinationWarehouseCode: { type: string } + destinationWarehouseName: { type: string } + fbaWarehouseCode: { type: string } + packageCount: { type: integer } + volumeCbm: { type: number } + sourceStage: { type: string, enum: [PLAN, RECEIVED] } + RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] } + RelabelCreate: + type: object + required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] + properties: + customerOrderNo: { type: string, maxLength: 128 } + overseasWarehouseId: { type: string, maxLength: 128 } + labelDeadline: { type: string, format: date-time } + contactName: { type: string, maxLength: 128 } + contactPhone: { type: string, maxLength: 64 } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } } + RelabelItem: + type: object + required: [skuCode, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode] + properties: + skuCode: { type: string, maxLength: 128 } + productImageFileId: { type: string, nullable: true, maxLength: 128 } + productImageFileName: { type: string, nullable: true, maxLength: 255 } + productImageUrl: { type: string, nullable: true, maxLength: 1000 } + productNameZh: { type: string, maxLength: 255 } + totalPackageCount: { type: integer, minimum: 1 } + notifiedPackageCount: { type: integer, minimum: 1 } + shipmentNo: { type: string, maxLength: 128 } + trackingCode: { type: string, nullable: true, maxLength: 255 } + newSkuCode: { type: string, maxLength: 128 } + labelRequirement: { type: string, nullable: true, maxLength: 1000 } + relabelFileId: { type: string, nullable: true, maxLength: 128 } + relabelFileName: { type: string, nullable: true, maxLength: 255 } + relabelFileUrl: { type: string, nullable: true, maxLength: 1000 } + RelabelStatusChange: + type: object + required: [status] + properties: + status: { $ref: '#/components/schemas/RelabelStatus' } + remark: { type: string, nullable: true, maxLength: 1000 } + ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } + ProcurementReview: + type: object + required: [version, decision, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + decision: { type: string, enum: [APPROVE, REJECT] } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + ProcurementCreate: + type: object + required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey] + properties: + customerName: { type: string, nullable: true, maxLength: 255 } + purchaseCategory: { type: string, maxLength: 128 } + productName: { type: string, maxLength: 255 } + productSpecification: { type: string, nullable: true, maxLength: 500 } + productUrl: { type: string, nullable: true, maxLength: 2000 } + purchaseQuantity: { type: integer, format: int64, minimum: 1 } + budgetUnitPrice: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + receivingAddress: { type: string, nullable: true, maxLength: 1000 } + contactName: { type: string, maxLength: 128 } + contactPhone: { type: string, maxLength: 64 } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + NullableTextPatch: { type: string, nullable: true, description: 缺失不修改;null 或空串清空 } + ProcurementUpdate: + type: object + required: [version] + properties: + version: { type: integer, format: int64, minimum: 0 } + purchaseCategory: { type: string, maxLength: 128 } + productName: { type: string, maxLength: 255 } + productSpecification: { $ref: '#/components/schemas/NullableTextPatch' } + productUrl: { $ref: '#/components/schemas/NullableTextPatch' } + inboundQuantity: { type: integer, format: int64, minimum: 0 } + purchaseWarehouseId: { $ref: '#/components/schemas/NullableTextPatch' } + purchaseWarehouseName: { $ref: '#/components/schemas/NullableTextPatch' } + domesticCarrier: { $ref: '#/components/schemas/NullableTextPatch' } + domesticTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' } + overseasCarrier: { $ref: '#/components/schemas/NullableTextPatch' } + overseasTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' } + estimatedArrivalAt: { type: string, format: date-time, nullable: true, description: 缺失不修改;null 或空串清空 } + remark: { $ref: '#/components/schemas/NullableTextPatch' } + CustomerOptionResponse: + type: object + properties: + code: { type: string, enum: ['200'] } + data: { type: array, items: { type: object, properties: { customerId: { type: string }, customerName: { type: string } } } } + WarehouseOptionResponse: + type: object + properties: + code: { type: string, enum: ['200'] } + data: { type: array, items: { type: object, properties: { warehouseId: { type: string }, warehouseCode: { type: string }, warehouseName: { type: string }, countryCode: { type: string } } } } + TruckStatus: { type: string, enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED] } + TruckReview: + type: object + required: [version, decision, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + decision: { type: string, enum: [APPROVE, REJECT] } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + TruckCancel: + type: object + required: [version, status, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + status: { type: string, enum: [CANCELLED] } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + TruckReceivePallets: + type: object + required: [version, actualPalletCount, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + actualPalletCount: { type: integer, minimum: 1 } + packageCount: { type: integer, nullable: true, minimum: 0 } + palletPackageCounts: { type: array, maxItems: 500, items: { type: integer, minimum: 0 } } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + TruckReviewPalletOverage: + allOf: [{ $ref: '#/components/schemas/TruckReview' }] + TruckAllocatePallets: + type: object + required: [version, palletIds, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + palletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: integer, format: int64, minimum: 1 } } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + TruckCommand: + type: object + required: [version, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + TruckCreate: + type: object + required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey] + properties: + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + palletCount: { type: integer, minimum: 1 } + packageCount: { type: integer, nullable: true, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + contactName: { type: string, maxLength: 128 } + contactInfo: { type: string, maxLength: 128 } + requestedDate: { type: string, format: date } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + TruckUpdate: + type: object + required: [version] + properties: + version: { type: integer, format: int64, minimum: 0 } + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + palletCount: { type: integer, minimum: 1 } + packageCount: { type: integer, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3 } + contactName: { type: string, maxLength: 128 } + contactInfo: { type: string, maxLength: 128 } + requestedDate: { type: string, format: date } + remark: { type: string, nullable: true, maxLength: 1000 } + LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] } + LooseboardStatusChange: + type: object + required: [version, status, idempotencyKey] + properties: + version: { type: integer, format: int64, minimum: 0 } + status: { $ref: '#/components/schemas/LooseboardStatus' } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 1000 } + LooseboardCreate: + type: object + required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] + properties: + transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } + palletRequired: { type: string, enum: [YES, NO] } + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + packageCount: { type: integer, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + contactName: { type: string, nullable: true, maxLength: 128 } + contactInfo: { type: string, nullable: true, maxLength: 128 } + requestedDate: { type: string, format: date, nullable: true } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + LooseboardUpdate: + type: object + required: [version, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm] + properties: + version: { type: integer, format: int64, minimum: 0 } + transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } + palletRequired: { type: string, enum: [YES, NO] } + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + packageCount: { type: integer, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + contactName: { type: string, nullable: true, maxLength: 128 } + contactInfo: { type: string, nullable: true, maxLength: 128 } + requestedDate: { type: string, format: date, nullable: true } + remark: { type: string, nullable: true, maxLength: 1000 } + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } diff --git a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml new file mode 100644 index 000000000..06a85196f --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml @@ -0,0 +1,298 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas Master Data API + version: 1.0.0 + description: 海外仓报价、月度汇率与车辆主数据;车辆契约不包含派送履约历史。 +paths: + /api/platform/v1/last-leg/master-data/overseas-quotes: + get: + summary: 分页查询海外仓报价 + description: '权限:platform:overseas-master-data:quote:list' + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + - { name: keyword, in: query, schema: { type: string } } + - { name: warehouseId, in: query, schema: { type: string } } + - { name: providerName, in: query, schema: { type: string } } + - { name: currency, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: 业务响应,data为PageResult } } + post: + summary: 创建海外仓报价 + description: '权限:platform:overseas-master-data:quote:create;服务端读取国内overseas-partners并缓存合作仓目录30分钟' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteCreate' } } } } + responses: { '200': { description: 业务响应,data为报价详情 } } + /api/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options: + get: + summary: 查询缓存的有效海外合作仓选项 + description: '权限:platform:overseas-master-data:quote:list;浏览器不直接调用国内内部接口;非空结果缓存30分钟,空结果不缓存;国内合作仓变更后可使用refresh=true强制刷新' + parameters: + - name: refresh + in: query + required: false + description: 是否绕过仍有效的非空缓存并重新读取国内海外合作仓 + schema: { type: boolean, default: false } + responses: + '200': + description: 业务响应,data为海外合作仓候选数组 + content: + application/json: + schema: + type: object + properties: + code: { type: string, example: '200' } + data: + type: array + items: { $ref: '#/components/schemas/PartnerWarehouseOption' } + /api/platform/v1/last-leg/master-data/overseas-quotes/export: + get: + summary: 导出海外仓报价 + description: '权限:platform:overseas-master-data:quote:export;UTF-8 BOM CSV' + parameters: + - { name: keyword, in: query, schema: { type: string } } + - { name: warehouseId, in: query, schema: { type: string } } + - { name: providerName, in: query, schema: { type: string } } + - { name: currency, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } + /api/platform/v1/last-leg/master-data/overseas-quotes/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + get: + summary: 查询海外仓报价详情 + description: '权限:platform:overseas-master-data:quote:list' + responses: { '200': { description: 业务响应 } } + put: + summary: 编辑未生效的海外仓报价 + description: '权限:platform:overseas-master-data:quote:edit;recordVersion乐观锁' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteUpdate' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/overseas-quotes/{id}/status: + patch: + summary: 启停海外仓报价 + description: '权限:platform:overseas-master-data:quote:status' + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteStatusChange' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/exchange-rates: + get: + summary: 分页查询月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:list' + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + - { name: monthFrom, in: query, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } } + - { name: monthTo, in: query, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: 业务响应,data为PageResult } } + post: + summary: 创建月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:create;月份唯一,初始状态ACTIVE' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateCreate' } } } } + responses: { '200': { description: 业务响应,data为MonthlyExchangeRate } } + /api/platform/v1/last-leg/master-data/exchange-rates/summary: + get: + summary: 查询最新有效月度汇率摘要 + description: '权限:platform:overseas-master-data:exchange-rate:list' + responses: { '200': { description: 业务响应,data含latestMonth/latestUsdToCnyAverage/updatedBy/updatedAt } } + /api/platform/v1/last-leg/master-data/exchange-rates/export: + get: + summary: 导出筛选后的全部月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:export;UTF-8 BOM CSV' + parameters: + - { name: monthFrom, in: query, schema: { type: string } } + - { name: monthTo, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } + /api/platform/v1/last-leg/master-data/exchange-rates/{month}: + parameters: + - { name: month, in: path, required: true, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } } + get: + summary: 查询月度汇率详情 + description: '权限:platform:overseas-master-data:exchange-rate:list' + responses: { '200': { description: 业务响应,data为MonthlyExchangeRate } } + put: + summary: 编辑月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:edit;recordVersion冲突返回业务错误' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateUpdate' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/exchange-rates/{month}/status: + patch: + summary: 变更月度汇率状态 + description: '权限:platform:overseas-master-data:exchange-rate:status' + parameters: + - { name: month, in: path, required: true, schema: { type: string } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateStatus' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/vehicles: + get: + summary: 分页查询车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:list' + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + - { name: keyword, in: query, description: 车牌/车型/司机或外派单位, schema: { type: string } } + - { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } } + - { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } } + responses: { '200': { description: 业务响应,data为PageResult } } + post: + summary: 创建车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:create;车牌号唯一' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleCreate' } } } } + responses: { '200': { description: 业务响应,data为Vehicle } } + /api/platform/v1/last-leg/master-data/vehicles/export: + get: + summary: 导出筛选后的全部车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:export;UTF-8 BOM CSV' + parameters: + - { name: keyword, in: query, schema: { type: string } } + - { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } } + - { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } } + responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } + /api/platform/v1/last-leg/master-data/vehicles/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + get: + summary: 查询车辆档案详情 + description: '权限:platform:overseas-master-data:vehicle:list;不包含派送履约或车辆更换历史' + responses: { '200': { description: 业务响应,data为Vehicle } } + put: + summary: 编辑车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:edit;状态由独立接口变更' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleUpdate' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/vehicles/{id}/status: + patch: + summary: 变更车辆状态 + description: '权限:platform:overseas-master-data:vehicle:status;使用recordVersion乐观锁' + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleStatusChange' } } } } + responses: { '200': { description: 业务响应 } } +components: + parameters: + Page: { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + Size: { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + schemas: + ActiveStatus: { type: string, enum: [ACTIVE, INACTIVE] } + PartnerWarehouseOption: + type: object + required: [warehouseId, warehouseCode, warehouseName, countryCode] + properties: + warehouseId: { type: string, description: 平台公共海外合作仓雪花ID字符串 } + warehouseCode: { type: string, description: 仓库编码 } + warehouseName: { type: string, description: 仓库名称 } + countryCode: { type: string, pattern: '^[A-Z]{2}$', description: ISO国家二字码 } + QuoteFee: + type: object + required: [item, billingUnit, unitPrice] + properties: + item: { type: string, maxLength: 128 } + billingUnit: { type: string, maxLength: 64 } + unitPrice: { type: number, minimum: 0 } + minimumCharge: { type: number, minimum: 0, nullable: true } + remark: { type: string, maxLength: 500, nullable: true } + QuoteTransitPrice: + type: object + required: [fbaWarehouseCode, deliveryMethod, price] + properties: + fbaWarehouseCode: { type: string, maxLength: 64 } + deliveryMethod: { type: string, maxLength: 64 } + estimatedMiles: { type: number, minimum: 0, nullable: true } + price: { type: number, minimum: 0 } + minimumCharge: { type: number, minimum: 0, nullable: true } + remark: { type: string, maxLength: 500, nullable: true } + QuoteCreate: + type: object + required: [name, warehouseId, providerName, currency, effectiveStart, effectiveEnd, versionCode, warehouseFees] + properties: + name: { type: string, maxLength: 128 } + warehouseId: { type: string, description: 国内平台海外合作仓ID } + providerName: { type: string, maxLength: 128 } + currency: { type: string, minLength: 3, maxLength: 3 } + effectiveStart: { type: string, format: date } + effectiveEnd: { type: string, format: date } + versionCode: { type: string, maxLength: 64 } + warehouseFees: { type: array, minItems: 1, items: { $ref: '#/components/schemas/QuoteFee' } } + transitPrices: { type: array, items: { $ref: '#/components/schemas/QuoteTransitPrice' } } + remark: { type: string, maxLength: 1000, nullable: true } + QuoteUpdate: + type: object + required: [recordVersion, name, providerName, currency, effectiveStart, effectiveEnd, warehouseFees] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + name: { type: string, maxLength: 128 } + providerName: { type: string, maxLength: 128 } + currency: { type: string, minLength: 3, maxLength: 3 } + effectiveStart: { type: string, format: date } + effectiveEnd: { type: string, format: date } + warehouseFees: { type: array, minItems: 1, items: { $ref: '#/components/schemas/QuoteFee' } } + transitPrices: { type: array, items: { $ref: '#/components/schemas/QuoteTransitPrice' } } + remark: { type: string, maxLength: 1000, nullable: true } + QuoteStatusChange: + type: object + required: [status, recordVersion] + properties: + status: { $ref: '#/components/schemas/ActiveStatus' } + recordVersion: { type: integer, format: int64, minimum: 0 } + ExchangeRateCreate: + type: object + required: [month, usdToCnyAverage] + properties: + month: { type: string, example: '2026-08' } + usdToCnyAverage: { type: number, exclusiveMinimum: true, minimum: 0, example: 7.18540000 } + remark: { type: string, maxLength: 1000, nullable: true } + ExchangeRateUpdate: + type: object + required: [recordVersion, usdToCnyAverage] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + usdToCnyAverage: { type: number, exclusiveMinimum: true, minimum: 0 } + remark: { type: string, maxLength: 1000, nullable: true } + ExchangeRateStatus: + type: object + required: [status, recordVersion] + properties: + status: { $ref: '#/components/schemas/ActiveStatus' } + recordVersion: { type: integer, format: int64, minimum: 0 } + remark: { type: string, maxLength: 500, nullable: true } + VehicleOwnership: { type: string, enum: [OWNED, DISPATCHED] } + VehicleRegion: { type: string, enum: [DOMESTIC, OVERSEAS] } + VehicleStatus: { type: string, enum: [IN_SERVICE, DISPATCHED, MAINTENANCE, INACTIVE, RETURNED] } + VehicleCreate: + type: object + required: [plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership, status] + properties: + plateNumber: { type: string, maxLength: 64 } + model: { type: string, maxLength: 128 } + fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 } + mileageKm: { type: integer, format: int64, minimum: 0 } + driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true } + phone: { type: string, maxLength: 64, nullable: true } + region: { $ref: '#/components/schemas/VehicleRegion' } + ownership: { $ref: '#/components/schemas/VehicleOwnership' } + status: { $ref: '#/components/schemas/VehicleStatus' } + remark: { type: string, maxLength: 1000, nullable: true } + VehicleUpdate: + type: object + required: [recordVersion, plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + plateNumber: { type: string, maxLength: 64 } + model: { type: string, maxLength: 128 } + fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 } + mileageKm: { type: integer, format: int64, minimum: 0, description: 不得小于已有公里码 } + driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true } + phone: { type: string, maxLength: 64, nullable: true } + region: { $ref: '#/components/schemas/VehicleRegion' } + ownership: { $ref: '#/components/schemas/VehicleOwnership' } + remark: { type: string, maxLength: 1000, nullable: true } + VehicleStatusChange: + type: object + required: [status, recordVersion] + properties: + status: { $ref: '#/components/schemas/VehicleStatus' } + recordVersion: { type: integer, format: int64, minimum: 0 } diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml new file mode 100644 index 000000000..e455f6f48 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -0,0 +1,256 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas PDA Warehouse Operations API + version: 1.0.0 + description: 海外仓PDA盘点、同仓整托移库、托盘作业与换标现场作业;全部范围从PDA Token解析。 +security: + - bearerAuth: [] +paths: + /api/pda/v1/last-leg/stocktakes: + get: + summary: 查询Token绑定仓盘点任务 + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + - { name: status, in: query, schema: { type: string, enum: [IN_PROGRESS, SUBMITTED] } } + responses: { '200': { description: 盘点任务分页 } } + post: + summary: 按库位或托盘创建盘点任务 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCreate' } } } } + responses: { '200': { description: 创建后的完整盘点任务 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}: + get: + summary: 查询盘点任务详情 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 盘点任务及冻结库存位置 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}/counts: + post: + summary: 提交一个库存位置的实盘箱数 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCount' } } } } + responses: { '200': { description: 已计数明细 }, '409': { description: 版本或幂等冲突 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}/submit: + post: + summary: 提交盘点并按差异调整库存 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeSubmit' } } } } + responses: { '200': { description: 已提交盘点任务 }, '409': { description: 未全盘、库存版本变化或负库存风险 } } + /api/pda/v1/last-leg/inventory-moves: + post: + summary: 同仓整托移库 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMove' } } } } + responses: { '200': { description: 移库结果 }, '409': { description: 跨仓、资源或库存版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/pallets/resolve: + post: + summary: 扫码查询托盘及库存内容 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } } + responses: { '200': { description: Token绑定仓托盘内容 } } + /api/pda/v1/last-leg/pallet-operations/assemble: + post: + summary: 将多个来源托盘的指定库存行组到目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletAssemble' } } } } + responses: { '200': { description: 组托及实物托盘状态结果 }, '409': { description: 目的仓、库存或资源版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/disassemble: + post: + summary: 将来源托盘库存拆到一个或多个目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletDisassemble' } } } } + responses: { '200': { description: 拆托结果 }, '409': { description: 库存或资源版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/swap: + post: + summary: 将整托库存更换到空目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletSwap' } } } } + responses: { '200': { description: 换托结果 }, '409': { description: 目标托盘非空或版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/unbind: + post: + summary: 解除整托库存与实物托盘绑定 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletUnbind' } } } } + responses: { '200': { description: 解绑结果 }, '409': { description: 库存或资源版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/candidates: + get: + summary: 查询Token绑定仓可执行换标任务 + responses: { '200': { description: 待认领、处理中或待复核换标任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}: + get: + summary: 查询换标现场任务详情 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 申请明细、逐件事实、照片与历史 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim: + post: + summary: 认领换标任务并推进申请至处理中 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } + responses: { '200': { description: 已认领任务 }, '409': { description: 仓库、状态或幂等冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units: + post: + summary: 登记一件正常换标事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } } + responses: { '200': { description: 更新后的任务 }, '409': { description: 旧标重复、新标不符或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip: + post: + summary: 登记一件有原因的跳过事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelSkip' } } } } + responses: { '200': { description: 更新后的任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions: + post: + summary: 登记一件换标异常事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelException' } } } } + responses: { '200': { description: 更新后的任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files: + post: + summary: 上传换标完成现场照片 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } + responses: { '200': { description: OVERSEAS_RELABEL_PHOTO文件元数据 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete: + post: + summary: 提交现场任务等待复核,不提前完成申请 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } } + responses: { '200': { description: WAIT_REVIEW任务 }, '409': { description: 箱数未核平、照片或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review: + post: + summary: 现场复核;APPROVE才完成既有换标申请,REWORK退回处理 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } } + responses: { '200': { description: COMPLETED或PROCESSING任务 }, '409': { description: 状态或版本冲突 } } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + schemas: + ScanCode: + type: object + required: [code] + properties: { code: { type: string, minLength: 1, maxLength: 128 } } + IdempotentAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + StocktakeCreate: + type: object + required: [scopeType, scopeCode, idempotencyKey] + properties: + scopeType: { type: string, enum: [LOCATION, PALLET] } + scopeCode: { type: string, maxLength: 128 } + idempotencyKey: { type: string, maxLength: 128 } + StocktakeCount: + type: object + required: [positionId, actualPackages, expectedVersion, idempotencyKey] + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + actualPackages: { type: integer, format: int64, minimum: 0 } + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, maxLength: 128 } + StocktakeSubmit: + type: object + required: [expectedVersion, reason, idempotencyKey] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + reason: { type: string, minLength: 1, maxLength: 500 } + idempotencyKey: { type: string, maxLength: 128 } + InventoryMove: + type: object + required: [sourcePalletCode, targetLocationCode, expectedPalletVersion, idempotencyKey, reason] + properties: + sourcePalletCode: { type: string, maxLength: 128 } + targetLocationCode: { type: string, maxLength: 128 } + expectedPalletVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 128 } + reason: { type: string, minLength: 1, maxLength: 500 } + PalletExpectation: + type: object + required: [palletCode, expectedVersion] + properties: + palletCode: { type: string, maxLength: 128 } + expectedVersion: { type: integer, minimum: 0 } + PalletMoveLine: + type: object + required: [positionId, packages, expectedPositionVersion] + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + packages: { type: integer, format: int64, minimum: 1 } + expectedPositionVersion: { type: integer, format: int64, minimum: 0 } + PalletAssemble: + type: object + required: [idempotencyKey, target, sources, lines] + properties: + idempotencyKey: { type: string, maxLength: 128 } + target: { $ref: '#/components/schemas/PalletExpectation' } + sources: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletExpectation' } } + lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/PalletMoveLine' } } + reason: { type: string, maxLength: 500 } + PalletTargetMove: + type: object + required: [target, lines] + properties: + target: { $ref: '#/components/schemas/PalletExpectation' } + lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/PalletMoveLine' } } + PalletDisassemble: + type: object + required: [idempotencyKey, source, targets] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + targets: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletTargetMove' } } + reason: { type: string, maxLength: 500 } + PalletSwap: + type: object + required: [idempotencyKey, source, target] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + target: { $ref: '#/components/schemas/PalletExpectation' } + reason: { type: string, maxLength: 500 } + PalletUnbind: + type: object + required: [idempotencyKey, source] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + reason: { type: string, maxLength: 500 } + RelabelUnit: + type: object + required: [itemId, oldLabel, newLabel, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255 } + newLabel: { type: string, maxLength: 255 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelSkip: + type: object + required: [itemId, oldLabel, reason, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255 } + reason: { type: string, maxLength: 1000 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelException: + type: object + required: [itemId, exceptionCode, reason, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255, nullable: true } + exceptionCode: { type: string, enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER] } + reason: { type: string, maxLength: 1000 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelComplete: + type: object + required: [photoFileIds, expectedVersion, idempotencyKey] + properties: + photoFileIds: { type: array, minItems: 1, maxItems: 20, items: { type: string, pattern: '^[0-9]+$' } } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } + RelabelReview: + type: object + required: [decision, expectedVersion, idempotencyKey] + properties: + decision: { type: string, enum: [APPROVE, REWORK] } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java new file mode 100644 index 000000000..5891459d7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeClientContractTest { + @Test + void canonicalDomesticBridgeEnvironmentOverridesLegacyNacosProperty() { + FeignClient contract = EventBridgeClient.class.getAnnotation(FeignClient.class); + + assertThat(contract.url()) + .isEqualTo("${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java new file mode 100644 index 000000000..94c1758ab --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.config; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LastlegRestClientFactoryTest { + private final LastlegRestClientFactory factory = new LastlegRestClientFactory(); + + @Test + void defaultsArePositiveAndSeparateFileUploadsFromWarehouseCalls() { + LastlegRestClientProperties properties = new LastlegRestClientProperties(); + + assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3)); + assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.getRelabelPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void rejectsUnboundedOrNonPositiveTimeouts() { + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(null, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ZERO, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ofSeconds(1), Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void bindsPerClientTimeoutOverridesFromConfiguration() { + var source = new MapConfigurationPropertySource(Map.of( + "baiyue.http.clients.unload-photo-file.connect-timeout", "125ms", + "baiyue.http.clients.unload-photo-file.read-timeout", "9s", + "baiyue.http.clients.pallet-physical.connect-timeout", "250ms", + "baiyue.http.clients.pallet-physical.read-timeout", "2s")); + + LastlegRestClientProperties properties = new Binder(source) + .bind("baiyue.http.clients", Bindable.of(LastlegRestClientProperties.class)) + .orElseThrow(() -> new AssertionError("Lastleg HTTP client properties were not bound")); + + assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofMillis(125)); + assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(9)); + assertThat(properties.getPalletPhysical().getConnectTimeout()).isEqualTo(Duration.ofMillis(250)); + assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void appliesConfiguredConnectAndReadTimeoutsWithoutRetryOrFallback() throws Exception { + var timeouts = new LastlegRestClientProperties.ClientTimeouts( + Duration.ofMillis(250), Duration.ofMillis(75)); + assertThat(factory.httpClient(timeouts).connectTimeout()).contains(Duration.ofMillis(250)); + + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/slow", exchange -> { + try { Thread.sleep(500); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + exchange.sendResponseHeaders(200, 0); + exchange.close(); + }); + server.start(); + try { + RestClient client = factory.build(RestClient.builder(), + "http://127.0.0.1:" + server.getAddress().getPort(), timeouts); + assertThatThrownBy(() -> client.get().uri("/slow").retrieve().toBodilessEntity()) + .isInstanceOf(ResourceAccessException.class); + } finally { + server.stop(0); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java new file mode 100644 index 000000000..abb7991c9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; + +class LastlegOrderControllerTest { + private final LastlegOrderService service = mock(LastlegOrderService.class); + private final LastlegOrderController controller = controller(); + private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null); + + @Test + void platformEndpointRejectsMerchantIdentity() { + assertThrows(ResponseStatusException.class, + () -> controller.list(user(100L, "MERCHANT"), "200", query)); + } + + @Test + void platformOperatorCanQueryAllCustomerTenantsWithoutCustomerId() { + controller.list(user(0L, "PLATFORM"), null, query); + verify(service).page(isNull(), eq(query)); + } + + @Test + void platformOperatorCanQueryExplicitCustomerTenant() { + controller.list(user(0L, "PLATFORM"), "200", query); + verify(service).page(eq("200"), eq(query)); + } + + @Test + void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() { + EventEnvelope event = mock(EventEnvelope.class); + when(event.eventType()).thenReturn(EventTypes.IAM_USER_STATUS_CHANGED); + LoadingCompletedConsumer loading = (LoadingCompletedConsumer) ReflectionTestUtils.getField(controller, "consumer"); + + assertThrows(ResponseStatusException.class, () -> controller.receive("internal-token", event)); + + verify(loading, never()).consume(event); + } + + private static AuthenticatedUser user(long customerId, String appCode) { + return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of()); + } + + private LastlegOrderController controller() { + LastlegOrderController controller = new LastlegOrderController(); + ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class)); + ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class)); + ReflectionTestUtils.setField(controller, "orderService", service); + ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class)); + return controller; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java new file mode 100644 index 000000000..c21728b09 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.service.LastlegOrderService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class MerchantLastlegOrderControllerTest { + private final LastlegOrderService service = mock(LastlegOrderService.class); + private final MerchantLastlegOrderController controller = controller(); + private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null); + + @Test + void merchantListUsesOnlyAuthenticatedTenant() { + controller.list(user(100L, "MERCHANT"), query); + verify(service).page(eq("100"), eq(query)); + } + + @Test + void merchantDetailUsesOnlyAuthenticatedTenant() { + controller.detail(user(100L, "MERCHANT"), 11L); + verify(service).detail("100", 11L); + } + + @Test + void merchantEndpointRejectsPlatformIdentity() { + assertThrows(ResponseStatusException.class, + () -> controller.list(user(0L, "PLATFORM"), query)); + } + + private static AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(1L, tenantId, tenantId, 1L, "tester", appCode, "WEB", Set.of()); + } + + private MerchantLastlegOrderController controller() { + MerchantLastlegOrderController controller = new MerchantLastlegOrderController(); + ReflectionTestUtils.setField(controller, "orderService", service); + return controller; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java new file mode 100644 index 000000000..850071823 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -0,0 +1,118 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.lastleg.delivery.controller.DeliveryTransferController; +import com.baiyue.lastleg.dispatch.controller.DispatchController; +import com.baiyue.lastleg.dispatch.controller.DispatchQueryController; +import com.baiyue.lastleg.dispatch.controller.SmartDispatchController; +import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; +import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; +import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.DeleteMapping; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Phase4BrowserApiBoundaryTest { + @Test + void onlyStableContainerReceivingUsesThePdaInboundAccessDomain() throws Exception { + assertTrue(basePath(ReceivingContainerController.class).startsWith("/api/pda/v1/")); + assertFalse(basePath(ReceivingContainerController.class).contains("/api/platform/")); + Path controllers = Path.of("src/main/java/com/baiyue/lastleg/inboundplan/pda/controller"); + String source = Files.walk(controllers).filter(path -> path.toString().endsWith(".java")) + .map(path -> { + try { return Files.readString(path); } + catch (Exception exception) { throw new IllegalStateException(exception); } + }).collect(Collectors.joining("\n")); + assertFalse(source.contains("/api/pda/v1/last-leg/inbound-plans/{planNo}")); + assertFalse(source.contains("@RequestParam(required = false) String customerId")); + } + + @Test + void excludedOutboundControllersAreDisabledUnlessExplicitlyEnabledLater() { + for (Class type : new Class[] { + DispatchController.class, DeliveryTransferController.class, DispatchPdaController.class + }) { + ConditionalOnProperty condition = type.getAnnotation(ConditionalOnProperty.class); + assertTrue(condition != null, type.getSimpleName()); + assertTrue(Arrays.asList(condition.name()).contains("lastleg.future-outbound-api.enabled")); + assertFalse(condition.matchIfMissing()); + } + } + + @Test + void dispatchQueryIsIndependentlyEnabledWithoutExposingOutboundCommands() { + ConditionalOnProperty condition = DispatchQueryController.class.getAnnotation(ConditionalOnProperty.class); + assertTrue(condition != null); + assertTrue(Arrays.asList(condition.name()).contains("lastleg.dispatch-query-api.enabled")); + assertFalse(condition.matchIfMissing()); + assertTrue(Arrays.stream(DispatchQueryController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 2); + assertFalse(Arrays.stream(DispatchQueryController.class.getDeclaredMethods()) + .flatMap(method -> Arrays.stream(method.getAnnotations())) + .anyMatch(annotation -> Set.of("PostMapping", "PutMapping", "DeleteMapping") + .contains(annotation.annotationType().getSimpleName()))); + } + + @Test + void dispatchQueryIsEnabledByDefaultWithoutEnablingOutboundCommands() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/application.yml")); + assertTrue(yaml.contains("enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true}")); + assertTrue(yaml.contains("enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}")); + assertFalse(yaml.contains("future-outbound-api:")); + } + + @Test + void smartDispatchOnlyExposesCandidatePreArrangeAndDraftLifecycleEndpoints() { + ConditionalOnProperty condition = SmartDispatchController.class.getAnnotation(ConditionalOnProperty.class); + assertTrue(condition != null); + assertTrue(Arrays.asList(condition.name()).contains("lastleg.smart-dispatch-api.enabled")); + assertFalse(condition.matchIfMissing()); + assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 2); + assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(PostMapping.class)).count() == 6); + assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(DeleteMapping.class)).count() == 1); + assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(DeleteMapping.class)) + .allMatch(method -> Arrays.asList(method.getAnnotation(DeleteMapping.class).value()) + .contains("/drafts/{draftToken}/containers/{containerId}"))); + assertFalse(Arrays.stream(SmartDispatchController.class.getDeclaredMethods()) + .flatMap(method -> Arrays.stream(method.getAnnotations())) + .anyMatch(annotation -> Set.of("PutMapping", "PatchMapping") + .contains(annotation.annotationType().getSimpleName()))); + } + + @Test + void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() { + Set paths = Arrays.stream(InventoryCommandController.class.getDeclaredMethods()) + .flatMap(method -> Arrays.stream(method.getAnnotations())) + .filter(annotation -> annotation.annotationType().getName().startsWith("org.springframework.web.bind.annotation")) + .flatMap(annotation -> { + try { + return Arrays.stream((String[]) annotation.annotationType().getMethod("value").invoke(annotation)); + } catch (ReflectiveOperationException ignored) { + return java.util.stream.Stream.empty(); + } + }).collect(Collectors.toSet()); + assertFalse(paths.contains("/allocate")); + assertFalse(paths.contains("/unallocate")); + assertFalse(paths.contains("/pick")); + assertFalse(paths.contains("/ship")); + } + + private static String basePath(Class type) { + return type.getAnnotation(RequestMapping.class).value()[0]; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java new file mode 100644 index 000000000..4dee1fd33 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DeliverySchemaContractTest { + @Test + void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception { + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + + assertTrue(schema.contains("create table if not exists ll_outbound_order")); + assertTrue(schema.contains("status varchar(32) not null default 'wait_outbound'")); + assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (customer_id,idempotency_key)")); + assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (customer_id,dispatch_order_id)")); + assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item")); + assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id)")); + assertTrue(schema.contains("destination_warehouse_code varchar(128) null")); + assertTrue(schema.contains("order_refs varchar(2000) null")); + assertTrue(schema.contains("inbound_code varchar(128) null")); + assertTrue(schema.contains("create table if not exists ll_delivery_order")); + assertTrue(schema.contains("status varchar(32) not null default 'wait_delivery'")); + assertTrue(schema.contains("unique key uk_ll_delivery_idempotency (customer_id,idempotency_key)")); + assertTrue(schema.contains("unique key uk_ll_delivery_outbound (customer_id,outbound_order_id)")); + } + + @Test + void deliveryPersistenceEntitiesUseSnowflakeIds() throws Exception { + for (Class type : new Class[] { OutboundOrder.class, OutboundPalletItem.class, DeliveryOrder.class }) { + TableId id = type.getDeclaredField("id").getAnnotation(TableId.class); + assertNotNull(id, type.getSimpleName()); + assertEquals(IdType.ASSIGN_ID, id.type(), type.getSimpleName()); + } + } + + @Test + void incrementalMigrationAddsTransferTablesAndIsRerunnable() throws Exception { + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + + assertTrue(migration.contains("2026-08-17 / 014")); + assertTrue(migration.contains("create table if not exists ll_outbound_order")); + assertTrue(migration.contains("create table if not exists ll_outbound_pallet_item")); + assertTrue(migration.contains("create table if not exists ll_delivery_order")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java new file mode 100644 index 000000000..29cbef8a2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -0,0 +1,284 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DeliveryTransferServiceTest { + @Mock DispatchOrderMapper dispatchOrders; + @Mock DispatchItemMapper dispatchItems; + @Mock OutboundOrderMapper outboundOrders; + @Mock OutboundPalletItemMapper outboundPallets; + @Mock DeliveryOrderMapper deliveryOrders; + @Mock InboundPlanPalletAllocationMapper planAllocations; + @Mock InboundPlanLineMapper planLines; + @Mock LastlegOrderMapper tailOrders; + @Mock LastlegOrderStatusHistoryMapper histories; + + private DeliveryTransferService service; + + @BeforeEach + void setUp() { + service = new DeliveryTransferService(); + ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders); + ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets); + ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); + ReflectionTestUtils.setField(service, "planAllocations", planAllocations); + ReflectionTestUtils.setField(service, "planLines", planLines); + ReflectionTestUtils.setField(service, "tailOrders", tailOrders); + ReflectionTestUtils.setField(service, "histories", histories); + } + + @Test + void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12), + item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8) + )); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer("transfer-101", "ready")); + + ArgumentCaptor outbound = ArgumentCaptor.forClass(OutboundOrder.class); + ArgumentCaptor delivery = ArgumentCaptor.forClass(DeliveryOrder.class); + ArgumentCaptor pallets = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundOrders).insert(outbound.capture()); + verify(deliveryOrders).insert(delivery.capture()); + verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture()); + + assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus()); + assertEquals(2, outbound.getValue().getPalletCount()); + assertEquals(20, outbound.getValue().getPackageCount()); + assertEquals("transfer-101", outbound.getValue().getIdempotencyKey()); + assertTrue(outbound.getValue().getOutboundNo().startsWith("OUT")); + assertEquals(outbound.getValue().getId(), delivery.getValue().getOutboundOrderId()); + assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus()); + assertTrue(delivery.getValue().getDeliveryNo().startsWith("DEL")); + assertEquals(List.of("ONT8", "LAX9"), pallets.getAllValues().stream() + .map(OutboundPalletItem::getDestinationWarehouseCode).toList()); + assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream() + .map(OutboundPalletItem::getInboundCode).toList()); + assertEquals(20, result.packageCount()); + assertEquals(2, result.palletCount()); + assertTrue(!result.replayed()); + } + + @Test + void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer(" transfer-101 ", null)); + + assertTrue(result.replayed()); + assertEquals("OUT-301", result.outboundNo()); + assertEquals("DEL-401", result.deliveryNo()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + verify(outboundPallets, never()).insert((OutboundPalletItem) any()); + verify(deliveryOrders, never()).insert((DeliveryOrder) any()); + } + + @Test + void idempotencyKeyCannotBeReusedForAnotherDispatch() { + DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A"); + OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-102")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 102L)).thenReturn(dispatch); + when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.transfer("TENANT-A", "DSP-102", "U-1", new DeliveryRequests.Transfer("shared-key", null))); + + assertEquals(409, exception.getStatusCode().value()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void concurrentDuplicateInsertReturnsWinningDocuments() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, winner, "DEL-401"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); + when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate")); + when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer("transfer-101", null)); + + assertTrue(result.replayed()); + assertEquals("OUT-301", result.outboundNo()); + assertEquals("DEL-401", result.deliveryNo()); + verify(outboundPallets, never()).insert((OutboundPalletItem) any()); + verify(deliveryOrders, never()).insert((DeliveryOrder) any()); + } + + @Test + void directDispatchOrderReferenceIsBoundToCreatedDocuments() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + DispatchItem source = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + source.setOrderId(901L); + LastlegOrder tailOrder = new LastlegOrder(); + tailOrder.setId(901L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("PENDING_ASSIGNMENT"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source)); + when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder)); + + service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("direct-order", null)); + + assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus()); + assertNotNull(tailOrder.getOutboundOrderNo()); + assertNotNull(tailOrder.getDeliveryOrderNo()); + verify(tailOrders).updateById(tailOrder); + verify(histories).insert(any(LastlegOrderStatusHistory.class)); + } + + @Test + void transferRejectsAPlannedPalletThatHasNotBeenReceived() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + DispatchItem planned = item(201L, null, "PLAN-1", "ONT8", "Ontario", "IN-A", 12); + planned.setSourceStage("PLAN"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("transfer-101", null))); + + assertEquals(409, exception.getStatusCode().value()); + assertTrue(exception.getReason().contains("received")); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void outboundDetailDoesNotExposeExcludedPalletOrOutboundFulfillmentRows() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); + when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + + DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301", + new DeliveryRequests.PalletQuery(1, 20, null)); + + assertEquals("OUT-301", detail.outbound().outboundNo()); + assertEquals("DEL-401", detail.delivery().deliveryNo()); + assertTrue(java.util.Arrays.stream(DeliveryResponses.OutboundDetail.class.getRecordComponents()) + .noneMatch(component -> "pallets".equals(component.getName()))); + verify(outboundPallets, never()).selectPage(any(Page.class), any()); + } + + @Test + void detailLookupCannotCrossTenantBoundary() { + when(outboundOrders.findByNo("TENANT-B", "OUT-301")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.outboundDetail("TENANT-B", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null))); + + assertEquals(404, exception.getStatusCode().value()); + verify(outboundOrders).findByNo("TENANT-B", "OUT-301"); + verify(deliveryOrders, never()).lockByOutbound(any(), any()); + verify(outboundPallets, never()).selectPage(any(), any()); + } + + private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) { + DispatchOrder value = new DispatchOrder(); + value.setId(id); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo); + value.setOverseasWarehouseId("WH-US-1"); value.setStatus("READY_TO_DEPART"); + return value; + } + + private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode, + String destinationName, String inboundCode, int packages) { + DispatchItem value = new DispatchItem(); + value.setId(id); value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo); + value.setDestinationWarehouseId("DEST-" + destinationCode); + value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName); + value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages); + value.setSourceStage("RECEIVED"); + return value; + } + + private static OutboundOrder outbound(Long id, Long dispatchId, String dispatchNo, String outboundNo, + String idempotencyKey) { + OutboundOrder value = new OutboundOrder(); + value.setId(id); value.setCustomerId("TENANT-A"); value.setDispatchOrderId(dispatchId); + value.setDispatchNo(dispatchNo); value.setOutboundNo(outboundNo); value.setStatus("WAIT_OUTBOUND"); + value.setOverseasWarehouseId("WH-US-1"); value.setPalletCount(2); value.setPackageCount(20); + value.setIdempotencyKey(idempotencyKey); value.setCreatedAt(LocalDateTime.now()); + return value; + } + + private static DeliveryOrder delivery(Long id, OutboundOrder outbound, String deliveryNo) { + DeliveryOrder value = new DeliveryOrder(); + value.setId(id); value.setCustomerId(outbound.getCustomerId()); value.setDeliveryNo(deliveryNo); + value.setOutboundOrderId(outbound.getId()); value.setOutboundNo(outbound.getOutboundNo()); + value.setDispatchOrderId(outbound.getDispatchOrderId()); value.setDispatchNo(outbound.getDispatchNo()); + value.setOverseasWarehouseId(outbound.getOverseasWarehouseId()); value.setStatus("WAIT_DELIVERY"); + value.setPalletCount(outbound.getPalletCount()); value.setPackageCount(outbound.getPackageCount()); + value.setCreatedAt(LocalDateTime.now()); + return value; + } + + private static OutboundPalletItem pallet(Long warehousePalletId, String palletNo, String destinationCode, + String destinationName, String inboundCode, int packages) { + OutboundPalletItem value = new OutboundPalletItem(); + value.setId(warehousePalletId + 1000); value.setCustomerId("TENANT-A"); value.setOutboundOrderId(301L); + value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo); + value.setDestinationWarehouseId("DEST-" + destinationCode); + value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName); + value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java new file mode 100644 index 000000000..58883d2ea --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java @@ -0,0 +1,164 @@ +package com.baiyue.lastleg.discard; + +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import org.mybatis.spring.annotation.MapperScan; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(classes = CargoDiscardMysqlAcceptanceTest.AcceptanceApplication.class, properties = { + "spring.sql.init.mode=never", + "spring.cloud.nacos.discovery.enabled=false", + "spring.cloud.nacos.discovery.register-enabled=false" +}) +@EnabledIfSystemProperty(named = "cargo.discard.acceptance", matches = "true") +class CargoDiscardMysqlAcceptanceTest { + private static final String CUSTOMER = "990001"; + + @Autowired private JdbcTemplate jdbc; + @Autowired private CargoDiscardService service; + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({CargoDiscardService.class, CargoDiscardInventoryAdjustmentService.class}) + @MapperScan({ + "com.baiyue.lastleg.mapper", + "com.baiyue.lastleg.discard.mapper", + "com.baiyue.lastleg.dropship.mapper" + }) + static class AcceptanceApplication { + } + + @Test + void createApprovalRejectionPendingAndInsufficientStockScenarios() { + resetAcceptanceTenant(); + seedOrder(990001001L, "RK-ACCEPT-APPROVE", "JF-ACCEPT-001", 20); + seedProfile(990001101L, 990001001L, "PALLET-A"); + seedSku(990001201L, 990001001L, 990001101L, "SKU-BOX-RED", "红色折叠收纳箱", 12); + seedSku(990001202L, 990001001L, 990001101L, "SKU-RACK-KITCHEN", "厨房置物架", 8); + seedCartons(990001001L, 990001101L, 990001301L, "PALLET-A", "SKU-BOX-RED", "RED", 12); + seedCartons(990001001L, 990001101L, 990001401L, "PALLET-A", "SKU-RACK-KITCHEN", "RACK", 8); + + seedOrder(990001002L, "RK-ACCEPT-REJECT", "JF-ACCEPT-002", 10); + seedProfile(990001102L, 990001002L, "PALLET-B"); + seedSku(990001203L, 990001002L, 990001102L, "SKU-LAMP-WHITE", "白色台灯", 10); + seedCartons(990001002L, 990001102L, 990001501L, "PALLET-B", "SKU-LAMP-WHITE", "LAMP", 10); + + seedOrder(990001003L, "RK-ACCEPT-PENDING", "JF-ACCEPT-003", 7); + seedProfile(990001103L, 990001003L, "PALLET-C"); + seedSku(990001204L, 990001003L, 990001103L, "SKU-MAT-BLUE", "蓝色瑜伽垫", 7); + seedCartons(990001003L, 990001103L, 990001601L, "PALLET-C", "SKU-MAT-BLUE", "MAT", 7); + + var approveRequest = new CargoDiscardRequests.Create(List.of( + new CargoDiscardRequests.Item(990001001L, "SKU-BOX-RED", 3), + new CargoDiscardRequests.Item(990001001L, "SKU-RACK-KITCHEN", 2)), + "ACCEPT-DISCARD-APPROVE", "验收:破损货物,同意废弃"); + var approved = service.create(CUSTOMER, "acceptance-creator", approveRequest); + assertEquals(20L, skuPackages(990001001L)); + approved = service.review(CUSTOMER, approved.summary().discardNo(), "acceptance-reviewer", + new CargoDiscardRequests.Review("APPROVE", "仓库复核破损属实")); + assertEquals(CargoDiscardService.APPROVED, approved.summary().status()); + assertEquals(15L, skuPackages(990001001L)); + + var rejected = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create( + List.of(new CargoDiscardRequests.Item(990001002L, "SKU-LAMP-WHITE", 4)), + "ACCEPT-DISCARD-REJECT", "验收:外箱可修复")); + assertEquals(10L, skuPackages(990001002L)); + rejected = service.review(CUSTOMER, rejected.summary().discardNo(), "acceptance-reviewer", + new CargoDiscardRequests.Review("REJECT", "复核后无需废弃")); + assertEquals(CargoDiscardService.REJECTED, rejected.summary().status()); + assertEquals(10L, skuPackages(990001002L)); + + var pending = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create( + List.of(new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 2)), + "ACCEPT-DISCARD-PENDING", "验收:等待仓库审核")); + assertEquals(CargoDiscardService.PENDING_REVIEW, pending.summary().status()); + assertEquals(7L, skuPackages(990001003L)); + + assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator", + new CargoDiscardRequests.Create(List.of( + new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 99)), + "ACCEPT-DISCARD-INSUFFICIENT", "验收:库存不足应失败"))); + + assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='APPROVED'")); + assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='REJECTED'")); + assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='PENDING_REVIEW'")); + assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001001 AND status='DISCARDED'")); + assertEquals(0, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001002 AND status='DISCARD_PENDING'")); + assertEquals(2, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001003 AND status='DISCARD_PENDING'")); + assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'")); + assertEquals(5, count("SELECT COUNT(DISTINCT idempotency_key) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'")); + String audit = jdbc.queryForObject("SELECT remark FROM ll_cargo_discard_status_history WHERE customer_id=? " + + "AND to_status='APPROVED'", String.class, CUSTOMER); + assertTrue(audit.contains("inventoryBefore=20, discarded=5, inventoryAfter=15")); + } + + private void resetAcceptanceTenant() { + jdbc.update("DELETE FROM ll_dropship_carton_ledger WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_status_history WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_carton WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_item WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_application WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_dropship_carton WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_dropship_sku_inventory WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_dropship_order_profile WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_order WHERE customer_id=?", CUSTOMER); + } + + private void seedOrder(long id, String inboundCode, String orderNo, int packages) { + jdbc.update("INSERT INTO ll_order(id,order_no,customer_id,order_type,inbound_code,source_region," + + "source_inbound_id,customer_name,overseas_warehouse_id,overseas_warehouse_name,pallet_count," + + "package_count,product_quantity,current_status,source_event_id,source_loading_plan_id,source_payload_hash," + + "inbound_order_no,created_at,updated_at) VALUES(?,?,?,'ONE_CLICK_DROPSHIP',?,'ACCEPTANCE',?," + + "'废弃验收客户','WH-US-LAX','美西洛杉矶仓',1,?,?, 'WAREHOUSED',?,?,?, ?,NOW(3),NOW(3))", + id, orderNo, CUSTOMER, inboundCode, "ACCEPT-" + id, packages, packages * 10, + "ACCEPT-EVENT-" + id, "ACCEPT-PLAN-" + id, "sha256:acceptance-" + id, orderNo); + } + + private void seedProfile(long id, long orderId, String palletNo) { + jdbc.update("INSERT INTO ll_dropship_order_profile(id,customer_id,order_id,warehouse_pallet_id,pallet_no," + + "source_pallet_no,status,idempotency_key,version,created_at,updated_at) " + + "VALUES(?,?,?, ?,?,?,'RECEIVED',?,0,NOW(3),NOW(3))", + id, CUSTOMER, orderId, id, palletNo, "SRC-" + palletNo, "ACCEPT-PROFILE-" + id); + } + + private void seedSku(long id, long orderId, long profileId, String sku, String name, int packages) { + jdbc.update("INSERT INTO ll_dropship_sku_inventory(id,customer_id,order_id,profile_id,sku_code,product_name," + + "package_count,product_quantity,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,NOW(3),NOW(3))", + id, CUSTOMER, orderId, profileId, sku, name, packages, packages * 10); + } + + private void seedCartons(long orderId, long profileId, long firstId, String palletNo, + String sku, String markPrefix, int packages) { + for (int index = 1; index <= packages; index++) { + jdbc.update("INSERT INTO ll_dropship_carton(id,customer_id,order_id,profile_id,warehouse_pallet_id," + + "pallet_no,sku_code,carton_mark,carton_sequence,status,version,created_at,updated_at) " + + "VALUES(?,?,?,?,?,?,?,?,?,'AVAILABLE',0,NOW(3),NOW(3))", + firstId + index - 1, CUSTOMER, orderId, profileId, profileId, palletNo, sku, + "ACCEPT-" + markPrefix + "-" + String.format("%03d", index), index); + } + } + + private long skuPackages(long orderId) { + return jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dropship_sku_inventory WHERE customer_id=? AND order_id=?", + Long.class, CUSTOMER, orderId); + } + + private int count(String sql) { + return jdbc.queryForObject(sql, Integer.class, CUSTOMER); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java new file mode 100644 index 000000000..9d4a5bd77 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.discard; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CargoDiscardSchemaContractTest { + @Test + void baselineContainsDiscardApplicationReservationAndHistoryTables() throws Exception { + String sql; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + sql = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_application")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_item")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history")); + assertTrue(sql.contains("UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id)")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java new file mode 100644 index 000000000..8dc597069 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.discard.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class CargoDiscardControllerTest { + @Mock CargoDiscardService service; + private CargoDiscardController controller; + + @BeforeEach + void setUp() { + controller = new CargoDiscardController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void reviewRequiresExplicitApprovalPermission() { + AuthenticatedUser user = user(Set.of()); + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.review( + user, null, "FH-1", new CargoDiscardRequests.Review("APPROVE", null))); + assertEquals(403, error.getStatusCode().value()); + } + + @Test + void merchantTenantComesFromTrustedIdentityWhenApproving() { + AuthenticatedUser user = user(Set.of(CargoDiscardService.APPROVAL_PERMISSION)); + CargoDiscardRequests.Review request = new CargoDiscardRequests.Review("APPROVE", "ok"); + controller.review(user, "999", "FH-1", request); + verify(service).review("100", "FH-1", "7", request); + } + + private AuthenticatedUser user(Set permissions) { + return new AuthenticatedUser(7L, 100L, 100L, 8L, "reviewer", "MERCHANT", "WEB", permissions); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java new file mode 100644 index 000000000..33b22eb50 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.discard.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class MerchantCargoDiscardControllerTest { + private final CargoDiscardService service = mock(CargoDiscardService.class); + private final MerchantCargoDiscardController controller = controller(); + private final CargoDiscardRequests.Query query = new CargoDiscardRequests.Query( + 1, 20, null, null, null, null, null, null); + + @Test + void merchantQueriesUseOnlyAuthenticatedTenant() { + AuthenticatedUser user = user(100L, "MERCHANT"); + controller.page(user, query); + controller.candidates(user, "IN001"); + controller.detail(user, "DS001"); + verify(service).page("100", query); + verify(service).candidates("100", "IN001"); + verify(service).detail("100", "DS001"); + } + + @Test + void merchantEndpointRejectsPlatformIdentity() { + assertThrows(ResponseStatusException.class, + () -> controller.page(user(0L, "PLATFORM"), query)); + } + + private static AuthenticatedUser user(long tenantId, String appCode) { + return new AuthenticatedUser(1L, tenantId, tenantId, 9L, "merchant", appCode, "WEB", Set.of()); + } + + private MerchantCargoDiscardController controller() { + MerchantCargoDiscardController controller = new MerchantCargoDiscardController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java new file mode 100644 index 000000000..c3a86f51f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java @@ -0,0 +1,108 @@ +package com.baiyue.lastleg.discard.service; + +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CargoDiscardInventoryAdjustmentServiceTest { + @Mock CargoDiscardInventoryMapper inventory; + @Mock DropshipCartonLedgerMapper ledgers; + private CargoDiscardInventoryAdjustmentService service; + + @BeforeEach + void setUp() { + service = new CargoDiscardInventoryAdjustmentService(); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "cartonLedgers", ledgers); + } + + @Test + void discardRequiresExistingTransactionAndConservesPackages() throws Exception { + Transactional transactional = CargoDiscardInventoryAdjustmentService.class + .getMethod("discard", String.class, CargoDiscardApplication.class, List.class, Map.class, + String.class, LocalDateTime.class).getAnnotation(Transactional.class); + assertEquals(Propagation.MANDATORY, transactional.propagation()); + CargoDiscardApplication application = application(2); + CargoDiscardItem item = item(2); + when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5)); + when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1); + when(inventory.skuPackages("100", 301L)).thenReturn(3L); + when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1))); + when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1); + + var result = service.discard("100", application, List.of(item), + Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now()); + + assertEquals(5, result.beforePackages()); + assertEquals(2, result.discardedPackages()); + assertEquals(3, result.afterPackages()); + ArgumentCaptor facts = ArgumentCaptor.forClass(DropshipCartonLedger.class); + verify(ledgers, times(2)).insert(facts.capture()); + assertTrue(facts.getAllValues().stream().allMatch(value -> "DISCARD".equals(value.getOperationType()) + && value.getIdempotencyKey().startsWith("CARGO_DISCARD:401:"))); + } + + @Test + void discardRejectsAnyPackageConservationMismatch() { + CargoDiscardApplication application = application(2); + CargoDiscardItem item = item(2); + when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5)); + when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1); + when(inventory.skuPackages("100", 301L)).thenReturn(4L); + when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1))); + when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1); + + assertThrows(ResponseStatusException.class, () -> service.discard("100", application, List.of(item), + Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now())); + } + + private CargoDiscardApplication application(int packages) { + CargoDiscardApplication value = new CargoDiscardApplication(); + value.setId(401L); value.setDiscardNo("FH-1"); value.setTotalPackages(packages); value.setRemark("damaged"); + return value; + } + private CargoDiscardItem item(int packages) { + CargoDiscardItem value = new CargoDiscardItem(); + value.setId(501L); value.setOrderId(101L); value.setSkuInventoryId(301L); value.setSkuCode("SKU-1"); + value.setDiscardPackages(packages); return value; + } + private DropshipSkuInventory sku(long packages) { + DropshipSkuInventory value = new DropshipSkuInventory(); value.setId(301L); value.setPackageCount(packages); return value; + } + private CargoDiscardCarton link(long id) { + CargoDiscardCarton value = new CargoDiscardCarton(); value.setItemId(501L); value.setCartonId(id); return value; + } + private DropshipCarton carton(long id) { + DropshipCarton value = new DropshipCarton(); value.setId(id); value.setCartonMark("C-" + id); return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java new file mode 100644 index 000000000..ce9707e49 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -0,0 +1,180 @@ +package com.baiyue.lastleg.discard.service; + +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory; +import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CargoDiscardServiceTest { + @Mock CargoDiscardApplicationMapper applications; + @Mock CargoDiscardItemMapper items; + @Mock CargoDiscardCartonMapper discardCartons; + @Mock CargoDiscardStatusHistoryMapper histories; + @Mock CargoDiscardInventoryMapper inventory; + @Mock CargoDiscardInventoryAdjustmentService adjustments; + @Mock LastlegOrderMapper orders; + + private CargoDiscardService service; + private final AtomicReference storedApplication = new AtomicReference<>(); + private final List storedItems = new ArrayList<>(); + private final List storedLinks = new ArrayList<>(); + private final List storedHistories = new ArrayList<>(); + + @BeforeEach + void setUp() { + service = new CargoDiscardService(); + ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "discardCartons", discardCartons); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "adjustments", adjustments); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00")); + lenient().when(applications.insert(any(CargoDiscardApplication.class))).thenAnswer(invocation -> { storedApplication.set(invocation.getArgument(0)); return 1; }); + lenient().when(items.insert(any(CargoDiscardItem.class))).thenAnswer(invocation -> { storedItems.add(invocation.getArgument(0)); return 1; }); + lenient().when(discardCartons.insert(any(CargoDiscardCarton.class))).thenAnswer(invocation -> { storedLinks.add(invocation.getArgument(0)); return 1; }); + lenient().when(histories.insert(any(CargoDiscardStatusHistory.class))).thenAnswer(invocation -> { storedHistories.add(invocation.getArgument(0)); return 1; }); + lenient().when(applications.findNo(any(), any())).thenAnswer(invocation -> storedApplication.get()); + lenient().when(items.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedItems)); + lenient().when(discardCartons.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedLinks)); + lenient().when(histories.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedHistories)); + } + + @Test + void createReservesExactSkuCartonsWithoutDeductingInventory() { + LastlegOrder order = order(); + DropshipSkuInventory sku = skuInventory(); + when(orders.selectById(101L)).thenReturn(order); + when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(3L); + when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L))); + when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku); + when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L); + when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1); + + var detail = service.create("100", "7", new CargoDiscardRequests.Create( + List.of(new CargoDiscardRequests.Item(101L, "SKU-1", 2)), "discard-create-1", "damaged")); + + assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status()); + assertEquals(2, detail.summary().totalPackages()); + assertEquals(new BigDecimal("10.00"), detail.summary().totalFee()); + assertEquals(2, storedLinks.size()); + verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt()); + } + + @Test + void approvalDeductsSkuInventoryAndWritesOneLedgerPerCarton() { + CargoDiscardApplication application = pendingApplication(); + storedApplication.set(application); + CargoDiscardItem item = item(); + storedItems.add(item); + storedLinks.add(link(item.getId(), 1L)); + storedLinks.add(link(item.getId(), 2L)); + when(applications.lockNo("100", "FH-1")).thenReturn(application); + when(adjustments.discard(eq("100"), eq(application), any(), any(), eq("9"), any())) + .thenReturn(new CargoDiscardInventoryAdjustmentService.AdjustmentResult(3, 2, 1)); + + var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok")); + + assertEquals(CargoDiscardService.APPROVED, detail.summary().status()); + verify(adjustments).discard(eq("100"), eq(application), any(), any(), eq("9"), any()); + assertEquals("ok; inventoryBefore=3, discarded=2, inventoryAfter=1", + storedHistories.get(storedHistories.size() - 1).getRemark()); + } + + @Test + void rejectionReleasesReservedCartonsWithoutDeduction() { + CargoDiscardApplication application = pendingApplication(); + storedApplication.set(application); + CargoDiscardItem item = item(); + storedItems.add(item); + storedLinks.add(link(item.getId(), 1L)); + when(applications.lockNo("100", "FH-1")).thenReturn(application); + when(inventory.changeCartonStatus("100", 1L, "DISCARD_PENDING", "AVAILABLE")).thenReturn(1); + + var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("REJECT", "not damaged")); + + assertEquals(CargoDiscardService.REJECTED, detail.summary().status()); + verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt()); + verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any()); + } + + private LastlegOrder order() { + LastlegOrder order = new LastlegOrder(); + order.setId(101L); order.setCustomerId("100"); order.setOrderType("ONE_CLICK_DROPSHIP"); + order.setInboundCode("RK-1"); order.setOverseasWarehouseId("WH-1"); order.setOverseasWarehouseName("LA"); + return order; + } + + private DropshipSkuInventory skuInventory() { + DropshipSkuInventory value = new DropshipSkuInventory(); + value.setId(301L); value.setCustomerId("100"); value.setOrderId(101L); value.setProfileId(201L); + value.setSkuCode("SKU-1"); value.setProductName("Box"); value.setPackageCount(3L); + return value; + } + + private DropshipCarton carton(Long id) { + DropshipCarton value = new DropshipCarton(); + value.setId(id); value.setCustomerId("100"); value.setOrderId(101L); value.setProfileId(201L); + value.setSkuCode("SKU-1"); value.setCartonMark("C-" + id); value.setStatus("DISCARD_PENDING"); + return value; + } + + private CargoDiscardApplication pendingApplication() { + CargoDiscardApplication value = new CargoDiscardApplication(); + value.setId(401L); value.setCustomerId("100"); value.setDiscardNo("FH-1"); value.setInboundCodes("RK-1"); + value.setOverseasWarehouseId("WH-1"); value.setTotalPackages(2); value.setFeePerPackage(new BigDecimal("5.00")); + value.setTotalFee(new BigDecimal("10.00")); value.setCurrency("USD"); value.setStatus(CargoDiscardService.PENDING_REVIEW); + return value; + } + + private CargoDiscardItem item() { + CargoDiscardItem value = new CargoDiscardItem(); + value.setId(501L); value.setApplicationId(401L); value.setCustomerId("100"); value.setOrderId(101L); + value.setInboundCode("RK-1"); value.setProfileId(201L); value.setSkuInventoryId(301L); + value.setSkuCode("SKU-1"); value.setAvailablePackagesSnapshot(3); value.setDiscardPackages(2); + return value; + } + + private CargoDiscardCarton link(Long itemId, Long cartonId) { + CargoDiscardCarton value = new CargoDiscardCarton(); + value.setId(600L + cartonId); value.setApplicationId(401L); value.setItemId(itemId); + value.setCustomerId("100"); value.setCartonId(cartonId); value.setCartonMark("C-" + cartonId); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java new file mode 100644 index 000000000..b3b0b1a3b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.dispatch; + +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DispatchCandidateMapperContractTest { + private static final List CONSTRUCTOR_ALIASES = List.of( + "planId", "planPalletId", "warehousePalletId", "orderId", "customerId", "customerName", + "planNo", "orderType", "volumeWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt", + "overseasWarehouseId", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo", + "destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode", + "packageCount", "volumeCbm", "sourceStage", "cartonMarks" + ); + + @Test + void candidateQueriesKeepRecordConstructorColumnOrder() throws Exception { + assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("available", + String.class, List.class, String.class, String.class, String.class, String.class)); + assertConstructorColumnOrder(DispatchCandidateMapper.class.getMethod("lockSelected", + String.class, List.class)); + } + + @Test + void selectedReceivedPalletMustStillHaveAvailableInventory() throws Exception { + Method method = DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class); + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + + assertTrue(sql.contains("p.warehouse_pallet_id IS NULL OR EXISTS")); + assertTrue(sql.contains("pos.available_packages > 0")); + } + + private void assertConstructorColumnOrder(Method method) { + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + int previous = -1; + for (String alias : CONSTRUCTOR_ALIASES) { + int current = sql.indexOf(" AS " + alias, previous + 1); + assertTrue(current > previous, + () -> method.getName() + " must select alias " + alias + " in record constructor order"); + previous = current; + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPalletLockPersistenceContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPalletLockPersistenceContractTest.java new file mode 100644 index 000000000..faf580f5f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPalletLockPersistenceContractTest.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.dispatch; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DispatchPalletLockPersistenceContractTest { + @Test + void nullableLockFieldsAreWrittenAsNullOnReleaseAndFinalization() throws Exception { + assertAlwaysUpdated("activeKey"); + assertAlwaysUpdated("draftToken"); + assertAlwaysUpdated("expiresAt"); + } + + @Test + void relockClearsInactiveUniqueKeyResidue() throws Exception { + Update update = DispatchPalletLockMapper.class + .getMethod("clearInactiveActiveKey", String.class, String.class) + .getAnnotation(Update.class); + String sql = String.join(" ", Arrays.asList(update.value())); + + assertTrue(sql.contains("active_key=NULL")); + assertTrue(sql.contains("status!='ACTIVE'")); + } + + private void assertAlwaysUpdated(String fieldName) throws Exception { + TableField field = DispatchPalletLock.class.getDeclaredField(fieldName).getAnnotation(TableField.class); + assertEquals(FieldStrategy.ALWAYS, field.updateStrategy()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java new file mode 100644 index 000000000..d2c064b77 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.dispatch; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DispatchSchemaContractTest { + @Test + void baselineDefinesDispatchHeadersItemsAndActivePalletLock() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_order")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_item")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock")); + assertTrue(sql.contains("uk_ll_dispatch_active_pallet")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation")); + assertTrue(sql.contains("active_key")); + assertTrue(sql.contains("draft_token")); + assertTrue(sql.contains("expires_at")); + assertTrue(sql.contains("dispatch_order_id BIGINT NULL")); + assertTrue(sql.contains("order_refs")); + } + + @Test + void dispatchCandidatesExcludePalletsReservedByWarehouseTransfers() throws Exception { + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java")); + assertTrue(mapper.contains("ll_warehouse_transfer_inventory_allocation")); + assertTrue(mapper.contains("ta.status='RESERVED'")); + assertTrue(mapper.contains("tp.pallet_id=p.warehouse_pallet_id")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java new file mode 100644 index 000000000..a5a775ba4 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.dispatch; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SmartDispatchOpenApiContractTest { + @Test + void contractDocumentsOnlyCandidateAndPreArrangeSmartDispatchWrites() throws Exception { + String yaml; + try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) { + yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/candidates:")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/pre-arrange:")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/lock:")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/unlock:")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/release:")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts:")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/snapshot:")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/containers:")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/{draftToken}/containers/{containerId}:")); + assertTrue(yaml.contains("draftContainerId:")); + assertTrue(yaml.contains("SmartDispatchDraftContainer:")); + assertTrue(yaml.contains("SmartDispatchDraft:")); + assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:list")); + assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch")); + assertTrue(yaml.contains("SmartDispatchPreArrange")); + assertTrue(yaml.contains("SmartDispatchCandidate")); + assertTrue(yaml.contains("name: planIds")); + String planIdsParameter = yaml.substring(yaml.indexOf("name: planIds"), + yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds"))); + assertTrue(planIdsParameter.contains("required: false")); + String dispatchContract = yaml.substring(0, + yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:")); + assertFalse(dispatchContract.contains("name: customerId")); + assertFalse(yaml.contains("/api/platform/v1/last-leg/dispatches/{dispatchNo}/depart:")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java new file mode 100644 index 000000000..acf14950a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DispatchQueryControllerTest { + @Mock DispatchService service; + private DispatchQueryController controller; + + @BeforeEach + void setUp() { + controller = new DispatchQueryController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void platformPageQueriesAcrossCustomersWithoutCustomerId() { + DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null); + when(service.platformPage(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + var response = controller.page(platform(), query); + + verify(service).platformPage(query); + assertThat(response.code()).isEqualTo("200"); + assertThat(response.data().records()).isEmpty(); + assertThatThrownBy(() -> controller.page(null, query)) + .isInstanceOf(ResponseStatusException.class) + .satisfies(error -> org.assertj.core.api.Assertions.assertThat( + ((ResponseStatusException) error).getStatusCode().value()).isEqualTo(401)); + } + + @Test + void listAndDetailReuseInboundOrderListAuthority() throws Exception { + PreAuthorize page = DispatchQueryController.class + .getMethod("page", AuthenticatedUser.class, DispatchRequests.Query.class) + .getAnnotation(PreAuthorize.class); + PreAuthorize detail = DispatchQueryController.class + .getMethod("detail", AuthenticatedUser.class, String.class) + .getAnnotation(PreAuthorize.class); + + assertThat(page.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST); + assertThat(detail.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST); + } + + private static AuthenticatedUser platform() { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java new file mode 100644 index 000000000..aef9e536f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SmartDispatchControllerTest { + @Mock DispatchService service; + private SmartDispatchController controller; + + @BeforeEach + void setUp() { + controller = new SmartDispatchController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void candidatesResolveCustomerScopeFromSelectedPlans() { + when(service.resolveCustomerScopeForPlans(List.of(1L, 2L))).thenReturn("100"); + when(service.candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN", "draft-1")) + .thenReturn(List.of()); + + controller.candidates(platform(), List.of(1L, 2L), "LMO-1", "ONT8", "PLAN", "draft-1"); + + verify(service).candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN", "draft-1"); + } + + @Test + void candidatesWithoutSelectedPlansUsePlatformScope() { + when(service.platformCandidates(null, null, "RECEIVED", "draft-2")).thenReturn(List.of()); + + controller.candidates(platform(), null, null, null, "RECEIVED", "draft-2"); + + verify(service).platformCandidates(null, null, "RECEIVED", "draft-2"); + } + + @Test + void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() { + DispatchRequests.PreArrange request = new DispatchRequests.PreArrange( + List.of(10L, 11L), "ONT8 -> LAX9", "APT-1", "SUCCESS", "PENDING"); + when(service.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100"); + + controller.preArrange(platform(), request); + + verify(service).preArrange("100", "7", request); + } + + @Test + void endpointsUseInboundOrderListAndDedicatedSmartDispatchAuthorities() throws Exception { + PreAuthorize candidates = SmartDispatchController.class + .getMethod("candidates", AuthenticatedUser.class, List.class, String.class, String.class, String.class, + String.class) + .getAnnotation(PreAuthorize.class); + PreAuthorize preArrange = SmartDispatchController.class + .getMethod("preArrange", AuthenticatedUser.class, DispatchRequests.PreArrange.class) + .getAnnotation(PreAuthorize.class); + + assertThat(candidates.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST); + assertThat(preArrange.value()).contains(OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH); + } + + private static AuthenticatedUser platform() { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", + Set.of(OverseasPermissions.INBOUND_ORDER_LIST, OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java new file mode 100644 index 000000000..675bf5e58 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DispatchPdaWorkflowServiceTest { + @Mock private DispatchOrderMapper orders; + @Mock private DispatchPdaOperationMapper operations; + @Mock private OutboundOrderMapper outboundOrders; + + @Test + void pushRequiresVehiclePlateBeforeCreatingPdaTask() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setVehiclePlate(" "); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void pushRequiresWarehouseSelection() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); + order.setStatus("PENDING_DEPARTURE"); order.setVehiclePlate("CA-100"); + order.setOverseasWarehouseId("WH-1"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null))); + + assertEquals(400, exception.getStatusCode().value()); + } + + @Test + void departRequiresVehiclePlate() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); + order.setStatus("READY_TO_DEPART"); order.setVehiclePlate(" "); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(operations.findKey("100", "depart-1")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void departRequiresPendingOutboundOrder() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); + order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(operations.findKey("100", "depart-1")).thenReturn(null); + when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void departedRequestReplaysAfterStatusChanged() throws Exception { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ObjectMapper objectMapper = new ObjectMapper(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setStatus("DEPARTED"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + DispatchPdaOperation operation = new DispatchPdaOperation(); + operation.setOperationType("DEPART"); operation.setStatus("COMPLETED"); + operation.setResultJson(objectMapper.writeValueAsString(new DispatchPdaResponses.Departed("PC1", "DEPARTED", 12, false))); + when(operations.findKey("100", "depart-1")).thenReturn(operation); + + // This exact key/hash pair models the persisted first execution result. + DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null); + operation.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + DispatchPdaResponses.Departed result = service.depart("100", "PC1", "operator", request); + + assertEquals("DEPARTED", result.status()); + assertEquals(true, result.replayed()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java new file mode 100644 index 000000000..ad9dcee13 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.ArrayList; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DispatchPickOrderAttributionServiceTest { + @Mock private DispatchPdaPickOrderMapper attributions; + @Mock private LastlegOrderMapper orders; + @Mock private LastlegOrderStatusHistoryMapper history; + + @Test + void completionStatusIsCalculatedBeforeMysqlMutatesShippedPackages() throws NoSuchMethodException { + Update update = LastlegOrderMapper.class.getMethod("transferPickedToShipped", + String.class, Long.class, long.class, String.class).getAnnotation(Update.class); + String sql = String.join("", Arrays.asList(update.value())).replaceAll("\\s+", " "); + + assertTrue(sql.indexOf("current_status=CASE") + < sql.indexOf("shipped_package_count=shipped_package_count+")); + } + + @Test + void attributesPackagesAcrossInboundOrdersInReceiptFifoOrder() { + DispatchPickOrderAttributionService service = service(); + InboundStockReceipt first = receipt(11L, 101L, 3L, 30L); + InboundStockReceipt second = receipt(12L, 102L, 4L, 40L); + when(attributions.availableReceipts("t1", 91L)).thenReturn(List.of(first, second)); + when(attributions.attributedPackages(eq("t1"), anyLong())).thenReturn(0L); + when(orders.increasePicked(eq("t1"), anyLong(), anyLong())).thenReturn(1); + DispatchPdaPick pick = pick(81L, 91L, 5L, 50L); + + List result = service.attribute("t1", pick); + + assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 3L), + new DispatchPickOrderAttributionService.OrderPackages(102L, 2L)), result); + } + + @Test + void departureMovesOnlyAttributedPackagesAndWritesOrderHistory() { + DispatchPickOrderAttributionService service = service(); + DispatchPdaPick pick = pick(81L, 91L, 4L, 40L); + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setOrderId(101L); row.setPackages(4L); + when(attributions.listPick("t1", 81L)).thenReturn(List.of(row)); + LastlegOrder order = new LastlegOrder(); + order.setId(101L); order.setPackageCount(10); order.setPickedPackageCount(4); + order.setShippedPackageCount(2); order.setCurrentStatus("INBOUND_COMPLETED"); + when(orders.lockById("t1", 101L)).thenReturn(order); + when(orders.transferPickedToShipped("t1", 101L, 4L, "OUT1")).thenReturn(1); + + List result = service.depart( + "t1", "OUT1", "D1", List.of(pick), LocalDateTime.of(2026, 8, 17, 15, 0)); + + assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 4L)), result); + verify(history).insert(argThat((LastlegOrderStatusHistory value) -> "PARTIALLY_SHIPPED".equals(value.getToStatus()) + && value.getRemark().contains("6/10"))); + } + + private DispatchPickOrderAttributionService service() { + DispatchPickOrderAttributionService service = new DispatchPickOrderAttributionService(); + ReflectionTestUtils.setField(service, "attributions", attributions); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "history", history); + return service; + } + + private static InboundStockReceipt receipt(long id, long orderId, long packages, long quantity) { + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(id); receipt.setOrderId(orderId); receipt.setReceivedPackages(packages); + return receipt; + } + + private static DispatchPdaPick pick(long id, long positionId, long packages, long quantity) { + DispatchPdaPick pick = new DispatchPdaPick(); + pick.setId(id); pick.setInventoryPositionId(positionId); pick.setPackages(packages); + pick.setProductQuantity(quantity); pick.setPickedAt(LocalDateTime.of(2026, 8, 17, 14, 0)); + return pick; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java new file mode 100644 index 000000000..95e14f0e3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -0,0 +1,274 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.List; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DispatchServiceTest { + @Mock DispatchOrderMapper orders; + @Mock DispatchItemMapper items; + @Mock DispatchPalletLockMapper locks; + @Mock DispatchCandidateMapper candidates; + @Mock DispatchDraftMapper drafts; + @Mock DispatchDraftContainerMapper draftContainers; + private DispatchService service; + + @BeforeEach + void setUp() { + service = new DispatchService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "locks", locks); + ReflectionTestUtils.setField(service, "candidates", candidates); + ReflectionTestUtils.setField(service, "drafts", drafts); + ReflectionTestUtils.setField(service, "draftContainers", draftContainers); + lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); + } + + @Test + void preArrangeLocksPalletWithoutVehicleCapacityValidation() throws Exception { + assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class, + DispatchRequests.PreArrange.class).getAnnotation(Transactional.class)); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + + var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, + null, null, null)); + + assertEquals(DispatchService.PENDING_DEPARTURE, result.status()); + assertEquals("ONT8", result.route()); + ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); + verify(orders).insert(dispatch.capture()); + assertEquals(1, dispatch.getValue().getPalletCount()); + verify(locks).insert(any(DispatchPalletLock.class)); + verify(items).insert(any(DispatchItem.class)); + } + + @Test + void movingPalletIntoDraftImmediatelyCreatesExpiringExclusiveLock() { + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + + service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1")); + + ArgumentCaptor lock = ArgumentCaptor.forClass(DispatchPalletLock.class); + verify(locks).insert(lock.capture()); + assertEquals("draft-1", lock.getValue().getDraftToken()); + assertEquals("7", lock.getValue().getOperatorId()); + assertNotNull(lock.getValue().getExpiresAt()); + assertEquals(null, lock.getValue().getDispatchOrderId()); + verify(locks).clearInactiveActiveKey("100", "PLAN:10"); + } + + @Test + void movingVolumeOnlyPlanPalletIntoDraftCreatesLock() { + when(candidates.lockSelected("100", List.of(10L))) + .thenReturn(List.of(candidate(10L, null, "ONT8", 0))); + + service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1")); + + verify(locks).insert(any(DispatchPalletLock.class)); + } + + @Test + void preArrangeAcceptsVolumeOnlyPlanPallet() { + when(candidates.lockSelected("100", List.of(10L))) + .thenReturn(List.of(candidate(10L, null, "ONT8", 0))); + + service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, + null, null, null)); + + ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); + verify(orders).insert(dispatch.capture()); + assertEquals(0, dispatch.getValue().getPackageCount()); + verify(items).insert(any(DispatchItem.class)); + } + + @Test + void removingPalletFromOwnedDraftImmediatelyReleasesLock() { + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + DispatchPalletLock lock = new DispatchPalletLock(); + lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10"); + when(locks.lockActive("100", "PLAN:10")).thenReturn(lock); + + service.unlockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1")); + + assertEquals("RELEASED", lock.getStatus()); + assertEquals(null, lock.getActiveKey()); + verify(locks).updateById(lock); + } + + @Test + void preArrangeConvertsOwnedDraftLockInsteadOfInsertingAnotherLock() { + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + DispatchPalletLock lock = new DispatchPalletLock(); + lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10"); + when(locks.lockActive("100", "PLAN:10")).thenReturn(lock); + + service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, "draft-1")); + + assertNotNull(lock.getDispatchOrderId()); + assertEquals(null, lock.getDraftToken()); + verify(locks).updateById(lock); + verify(locks, never()).insert(any(DispatchPalletLock.class)); + } + + @Test + void rejectsPalletAlreadyLockedByAnotherDispatch() { + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + DispatchPalletLock locked = new DispatchPalletLock(); + when(locks.lockActive(eq("100"), eq("PLAN:10"))).thenReturn(locked); + + assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); + verify(orders, never()).insert(any(DispatchOrder.class)); + } + + @Test + void invalidCandidateStageIsRejected() { + assertThrows(ResponseStatusException.class, + () -> service.candidates("100", List.of(1L), null, null, "INVALID")); + verify(candidates, never()).available(any(), any(), any(), any(), any(), any()); + } + + @Test + void platformCandidatesQueryAcrossCustomersWithoutPlanScope() { + when(candidates.available(null, null, null, null, "RECEIVED", "draft-1")).thenReturn(List.of()); + + service.platformCandidates(null, null, "RECEIVED", "draft-1"); + + verify(candidates).available(null, null, null, null, "RECEIVED", "draft-1"); + } + + @Test + void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() { + when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100")); + when(candidates.findCustomerScopesByPlanPalletIds(List.of(10L, 11L))).thenReturn(List.of("100")); + + assertEquals("100", service.resolveCustomerScopeForPlans(List.of(2L, 1L, 1L))); + assertEquals("100", service.resolveCustomerScopeForPallets(List.of(11L, 10L))); + } + + @Test + void rejectsSelectedPlansThatSpanCustomers() { + when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100", "200")); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.resolveCustomerScopeForPlans(List.of(1L, 2L))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void emptyCustomerScopeProducesEmptyCandidatesAndRejectsPreArrange() { + assertEquals(List.of(), service.candidates(null, null, null, null, null, "draft-1")); + verify(candidates, never()).available(any(), any(), any(), any(), any(), any()); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.preArrange(null, "7", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void editCannotBypassPdaManagedDispatchStatus() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); + dispatch.setStatus(DispatchService.PENDING_DEPARTURE); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(dispatch); + when(orders.lockById("100", 1L)).thenReturn(dispatch); + DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null, + LocalDateTime.now(), null, null, null, null, "COMPLETED"); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.update("100", "PC1", request)); + + assertEquals(400, exception.getStatusCode().value()); + assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); + verify(orders, never()).updateById(any(DispatchOrder.class)); + } + + @Test + void detailUsesResolvedCustomerScopeAndLoadsItems() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); + dispatch.setCustomerId("200"); + dispatch.setDispatchNo("PC1"); + when(orders.findByDispatchNo("200", "PC1")).thenReturn(dispatch); + + service.detail("200", "PC1"); + + verify(items).findByDispatchOrder("200", 1L); + } + + @Test + void platformPageQueriesAllCustomersAndLoadsItemsInEachCustomerScope() { + DispatchOrder first = new DispatchOrder(); + first.setId(1L); first.setCustomerId("100"); first.setDispatchNo("PC1"); + DispatchOrder second = new DispatchOrder(); + second.setId(2L); second.setCustomerId("200"); second.setDispatchNo("PC2"); + Page resultPage = new Page<>(1, 20, 2); + resultPage.setRecords(List.of(first, second)); + when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(resultPage); + + var result = service.platformPage(new DispatchRequests.Query(1, 20, null, null, null, null)); + + assertEquals(2, result.total()); + verify(items).findByDispatchOrder("100", 1L); + verify(items).findByDispatchOrder("200", 2L); + } + + @Test + void platformDetailUsesCustomerFromMatchedDispatch() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1"); + when(orders.findAcrossCustomersByDispatchNo("PC1")).thenReturn(List.of(dispatch)); + + service.platformDetail("PC1"); + + verify(items).findByDispatchOrder("200", 1L); + } + + private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { + return candidate(planPalletId, warehousePalletId, destination, 12); + } + + private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination, + int packageCount) { + return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", + "LIP1-P001", destination, destination, destination, destination, packageCount, + new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java new file mode 100644 index 000000000..adf1944e5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dropship; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class DropshipSchemaContractTest { + @Test + void baselineAndIncrementalSqlKeepContainerAndMultiPalletIndexes() throws Exception { + String baseline = Files.readString( + Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); + String migration = Files.readString( + Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + + assertThat(baseline).contains( + "key idx_ll_order_container(customer_id,container_no)", + "unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)", + "unique key uk_ll_dropship_sku_inventory(profile_id,sku_code)", + "key idx_ll_dropship_profile_order(customer_id,order_id)", + "warehouse_pallet_id bigint null comment '仓库实物托盘id'", + "pallet_no varchar(128) null comment '托盘号'", + "status varchar(32) not null default 'pending_inbound'"); + assertThat(migration).contains( + "add key idx_ll_order_container (customer_id,container_no)", + "add unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)", + "add unique key uk_ll_dropship_sku_profile(profile_id,sku_code)", + "add key idx_ll_dropship_profile_order(customer_id,order_id)", + "modify column warehouse_pallet_id bigint null", + "modify column pallet_no varchar(128) null"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java new file mode 100644 index 000000000..85564aa1f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java @@ -0,0 +1,148 @@ +package com.baiyue.lastleg.dropship.service; + +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DropshipOrderServiceTest { + @Mock private LastlegOrderMapper orders; + @Mock private DropshipOrderProfileMapper profiles; + @Mock private DropshipSkuInventoryMapper skuInventory; + @Mock private DropshipCartonMapper cartons; + @Mock private DropshipCartonLedgerMapper ledger; + @Mock private WarehouseResourceGateway warehouse; + @InjectMocks private DropshipOrderService service; + + @Test + void receivesMultiplePalletsForOneInboundOrder() { + LastlegOrder order = new LastlegOrder(); + order.setId(10L); + order.setCustomerId("C-1"); + order.setOrderType("ONE_CLICK_DROPSHIP"); + order.setOrderNo("LL-10"); + order.setPackageCount(3); + order.setProductQuantity(30); + when(orders.selectById(10L)).thenReturn(order); + when(profiles.listOrder("C-1", 10L)).thenReturn(List.of()); + + DropshipRequests.Receive request = new DropshipRequests.Receive("C-1", 10L, 20L, "EVENT-1", + List.of( + new DropshipRequests.Pallet("SOURCE-P-1", List.of( + new DropshipRequests.SkuPackages("SKU-A", "Product A", 2, 20, + List.of("MARK-1", "MARK-2")))), + new DropshipRequests.Pallet("SOURCE-P-2", List.of( + new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10, + List.of("MARK-3")))))); + + assertThat(service.receive(request)).isEqualTo(3); + verifyNoInteractions(warehouse); + verify(profiles, times(2)).insert(any(DropshipOrderProfile.class)); + verify(skuInventory, times(2)).insert(any(DropshipSkuInventory.class)); + verify(cartons, times(3)).insert(any(DropshipCarton.class)); + verify(ledger, times(3)).insert(any(DropshipCartonLedger.class)); + + ArgumentCaptor profilesCaptor = ArgumentCaptor.forClass(DropshipOrderProfile.class); + verify(profiles, times(2)).insert(profilesCaptor.capture()); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getSourcePalletNo) + .containsExactly("SOURCE-P-1", "SOURCE-P-2"); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getCustomerId) + .containsOnly("C-1"); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getStatus) + .containsOnly("PENDING_INBOUND"); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getWarehousePalletId) + .containsOnlyNulls(); + ArgumentCaptor cartonCaptor = ArgumentCaptor.forClass(DropshipCarton.class); + verify(cartons, times(3)).insert(cartonCaptor.capture()); + assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getStatus) + .containsOnly("PENDING_INBOUND"); + ArgumentCaptor ledgerCaptor = ArgumentCaptor.forClass(DropshipCartonLedger.class); + verify(ledger, times(3)).insert(ledgerCaptor.capture()); + assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getOperationType) + .containsOnly("SNAPSHOT_CREATED"); + assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages) + .containsOnly(0L); + } + + @Test + void activatesMultiplePalletsOnlyAfterPdaStockIn() { + DropshipOrderProfile first = profile(1L, "SOURCE-P-1"); + DropshipOrderProfile second = profile(2L, "SOURCE-P-2"); + when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(first, second)); + DropshipCarton firstCarton = carton(11L, 1L, "MARK-1"); + DropshipCarton secondCarton = carton(12L, 2L, "MARK-2"); + when(cartons.listProfile("C-1", 1L)).thenReturn(List.of(firstCarton)); + when(cartons.listProfile("C-1", 2L)).thenReturn(List.of(secondCarton)); + + service.activateAfterStockIn("C-1", 10L, 50L, List.of( + receipt(101L, "WH-P-1", 201L, 1), receipt(102L, "WH-P-2", 202L, 1))); + + assertThat(first.getStatus()).isEqualTo("ACTIVE"); + assertThat(first.getWarehousePalletId()).isEqualTo(101L); + assertThat(firstCarton.getStatus()).isEqualTo("AVAILABLE"); + assertThat(firstCarton.getWarehousePalletId()).isEqualTo(101L); + verify(profiles, times(2)).updateById(any(DropshipOrderProfile.class)); + verify(cartons, times(2)).updateById(any(DropshipCarton.class)); + ArgumentCaptor facts = ArgumentCaptor.forClass(DropshipCartonLedger.class); + verify(ledger, times(2)).insert(facts.capture()); + assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getOperationType).containsOnly("STOCK_IN"); + assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages).containsOnly(1L); + } + + @Test + void repeatedPdaActivationIsANoOp() { + DropshipOrderProfile active = profile(1L, "SOURCE-P-1"); + active.setStatus("ACTIVE"); + when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(active)); + + service.activateAfterStockIn("C-1", 10L, 50L, + List.of(receipt(101L, "WH-P-1", 201L, 1))); + + verify(profiles, never()).updateById(any(DropshipOrderProfile.class)); + verify(cartons, never()).updateById(any(DropshipCarton.class)); + verify(ledger, never()).insert(any(DropshipCartonLedger.class)); + } + + private static DropshipOrderProfile profile(long id, String sourcePalletNo) { + DropshipOrderProfile profile = new DropshipOrderProfile(); + profile.setId(id); profile.setCustomerId("C-1"); profile.setOrderId(10L); + profile.setSourcePalletNo(sourcePalletNo); profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L); + return profile; + } + + private static DropshipCarton carton(long id, long profileId, String mark) { + DropshipCarton carton = new DropshipCarton(); + carton.setId(id); carton.setCustomerId("C-1"); carton.setOrderId(10L); carton.setProfileId(profileId); + carton.setCartonMark(mark); carton.setSkuCode("SKU-A"); carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); + return carton; + } + + private static InboundStockReceipt receipt(long palletId, String palletNo, long positionId, long packages) { + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setOrderId(10L); receipt.setWarehousePalletId(palletId); receipt.setPalletNo(palletNo); + receipt.setInventoryPositionId(positionId); receipt.setReceivedPackages(packages); + return receipt; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java new file mode 100644 index 000000000..cb4671a44 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.inboundplan; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InboundPlanSchemaContractTest { + @Test + void planIsUniqueByCustomerAndSourceOrderWhileContainerCanBeShared() throws IOException { + try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) { + assertTrue(stream != null, "lastleg schema must be available"); + String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase(); + assertTrue(schema.contains("unique key uk_ll_inbound_plan_source_order (customer_id, source_order_id)")); + assertTrue(schema.contains("key idx_ll_inbound_plan_container (customer_id, container_no)")); + assertFalse(schema.contains("unique key uk_ll_inbound_plan_container")); + assertTrue(schema.contains("default 'pending_assignment'")); + assertTrue(schema.contains("required_package_count")); + assertTrue(schema.contains("planned_package_count")); + assertTrue(schema.contains("warehouse_resource_lock_id")); + assertTrue(schema.contains("create table if not exists ll_inbound_pda_task")); + assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation")); + assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge")); + assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment")); + assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)")); + assertTrue(schema.contains("expected_loading_at")); + assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item")); + assertTrue(schema.contains("fba_warehouse_code")); + assertFalse(schema.contains("cabinet_no")); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java new file mode 100644 index 000000000..e95ead766 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.inboundplan.controller; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Set; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundPlanControllerTest { + @Mock InboundPlanService service; + @Mock InboundFbaAppointmentService appointmentService; + private InboundPlanController controller; + + @BeforeEach + void setUp() { + controller = new InboundPlanController(); + ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "appointmentService", appointmentService); + } + + @Test + void platformPageAllowsAllCustomersWhenCustomerIdIsOmitted() { + InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, + null, null, null, null, null, null, null, null); + when(service.page(null, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + controller.page(platform(), null, query); + + verify(service).page(null, query); + } + + @Test + void platformAppointmentUpdateUsesExplicitCustomerScope() { + UpdateFbaAppointmentRequest request = new UpdateFbaAppointmentRequest("100", + LocalDateTime.of(2026, 8, 20, 10, 30), LocalDateTime.of(2026, 8, 21, 8, 0), + "PENDING", "dock"); + + controller.updateFbaAppointment(platform(), "APT-1", request); + + verify(appointmentService).update("100", "APT-1", request); + } + + private static AuthenticatedUser platform() { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", + Set.of("platform:overseas-warehouse:inbound-order:list")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java new file mode 100644 index 000000000..0fe426b28 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.inboundplan.controller; + +import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InboundPlanPermissionContractTest { + private static final String LIST = "platform:overseas-warehouse:inbound-order:list"; + private static final String CREATE = "platform:overseas-warehouse:inbound-order:create"; + private static final String FBA_APPOINTMENT = + "platform:overseas-warehouse:inbound-order:fba-appointment"; + + @Test + void listAndDetailUseOnlyCanonicalInboundOrderListAuthority() { + Map methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods()) + .collect(Collectors.toMap(Method::getName, method -> method)); + + for (String name : new String[] {"page", "detail", "fbaAppointmentDetail"}) { + Method method = methods.get(name); + assertEquals("hasAuthority('" + LIST + "')", method.getAnnotation(PreAuthorize.class).value()); + assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description()); + } + } + + @Test + void conversionUsesCanonicalInboundOrderCreateAuthority() { + Method method = Arrays.stream(InboundPlanController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals("convert")) + .findFirst() + .orElseThrow(); + + assertEquals("hasAuthority('" + CREATE + "')", method.getAnnotation(PreAuthorize.class).value()); + assertTrue(method.getAnnotation(Operation.class).description().contains("Required authority: " + CREATE)); + } + + @Test + void fbaAppointmentWritesUseDedicatedAuthority() { + Map methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods()) + .collect(Collectors.toMap(Method::getName, method -> method)); + + for (String name : new String[] {"batchFbaAppointment", "updateFbaAppointment"}) { + Method method = methods.get(name); + assertEquals("hasAuthority('" + FBA_APPOINTMENT + "')", + method.getAnnotation(PreAuthorize.class).value()); + assertEquals("Required authority: " + FBA_APPOINTMENT, + method.getAnnotation(Operation.class).description()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java new file mode 100644 index 000000000..65b4ae086 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -0,0 +1,168 @@ +package com.baiyue.lastleg.inboundplan.pda; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import jakarta.validation.constraints.Size; + +import java.io.IOException; +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReceivingContainerContractTest { + private static final Set FORBIDDEN_CALLER_SCOPE = Set.of( + "customerId", "planNo", "allocationId", "warehouseId", "locationId"); + + @Test + void pdaRequestsNeverAcceptServerOwnedScopeFields() { + for (Class type : ReceivingContainerRequests.class.getDeclaredClasses()) { + if (!type.isRecord()) continue; + Set fields = Arrays.stream(type.getRecordComponents()) + .map(RecordComponent::getName).collect(Collectors.toSet()); + assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName()); + } + for (Class type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Sign.class, + ReceivingContainerRequests.BulkReceive.class, ReceivingContainerRequests.CartonReceive.class)) { + RecordComponent key = Arrays.stream(type.getRecordComponents()) + .filter(component -> "idempotencyKey".equals(component.getName())).findFirst().orElseThrow(); + Size size = key.getAccessor().getAnnotation(Size.class); + assertTrue(size != null && size.max() == 255, type.getSimpleName()); + } + } + + @Test + void everyBrowserReachableReceivingMethodUsesOnlyPdaOperatePermission() { + PreAuthorize permission = ReceivingContainerController.class.getAnnotation(PreAuthorize.class); + assertTrue(permission != null); + assertTrue("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')" + .equals(permission.value())); + } + + @Test + void completedSessionAlwaysPersistsClearedGlobalLockKeys() throws NoSuchFieldException { + for (String fieldName : List.of("activeOperatorKey", "activeContainerKey")) { + TableField field = ReceivingContainerSession.class.getDeclaredField(fieldName) + .getAnnotation(TableField.class); + assertTrue(field != null && field.updateStrategy() == FieldStrategy.ALWAYS, fieldName); + } + } + + @Test + void inventoryCreatingEntryTransactionsAvoidMissingDimensionGapLockDeadlocks() throws NoSuchMethodException { + Transactional pda = ReceivingContainerService.class + .getMethod("complete", String.class, ReceivingContainerRequests.Action.class) + .getAnnotation(Transactional.class); + Transactional bridge = LoadingCompletedConsumer.class.getMethod("consume", EventEnvelope.class) + .getAnnotation(Transactional.class); + assertTrue(pda != null && pda.isolation() == Isolation.READ_COMMITTED); + assertTrue(bridge != null && bridge.isolation() == Isolation.READ_COMMITTED); + } + + @Test + void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException { + String schema = resource("db/lastleg.sql").toLowerCase(); + String unified = java.nio.file.Files.readString( + java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase(); + for (String table : Set.of("ll_inbound_plan_line", "ll_inbound_plan_pallet", + "ll_inbound_plan_pallet_allocation")) { + assertTrue(tableBlock(schema, table).contains("customer_id varchar(128) not null"), table); + assertTrue(unified.contains("table_name='" + table + "' and column_name='customer_id'"), table); + assertTrue(unified.contains("update " + table), table); + } + assertFalse(schema.contains("tenant_id varchar(64)")); + assertFalse(unified.contains("column_name='tenant_id'")); + assertTrue(schema.contains("create table if not exists ll_receiving_container_session")); + assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)")); + assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)")); + assertTrue(schema.contains("平台操作账号全局唯一锁键")); + assertTrue(schema.contains("柜号全局唯一锁键")); + assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); + assertTrue(schema.contains("unique key uk_ll_receiving_operation(operator_key,idempotency_key)")); + assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt")); + assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); + String receivingSession = tableBlock(schema, "ll_receiving_container_session"); + assertTrue(receivingSession.contains("sign_photos json")); + assertTrue(receivingSession.contains("overseas_warehouse_id bigint not null")); + assertTrue(receivingSession.contains("overseas_warehouse_name varchar(255) not null")); + assertFalse(receivingSession.contains("photo_file_ids json")); + assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt")); + assertTrue(unified.contains("column_name='sign_photos'")); + assertTrue(schema.contains("wait_sign、signed、receiving、completed")); + assertTrue(unified.contains("customer_id is null or l.customer_id=''")); + assertTrue(unified.contains("customer_id is null or v.customer_id=''")); + assertTrue(unified.contains("customer_id is null or a.customer_id=''")); + assertTrue(unified.contains("signal sqlstate '45000'")); + assertTrue(unified.contains("assert_receiving_global_locks_migratable")); + assertTrue(unified.contains("where status='completed' and (active_operator_key is not null or active_container_key is not null)")); + assertTrue(unified.contains("concat('operator:',operator_key)")); + assertTrue(unified.contains("concat('container:',upper(trim(container_no)))")); + assertTrue(unified.contains("assert_receiving_operation_global_key_migratable")); + assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'")); + assertTrue(unified.contains("assert_pending_orders_do_not_use_source_warehouse")); + assertTrue(unified.contains("assert_active_receiving_session_has_warehouse")); + } + + @Test + void openApiPublishesOnlyStableContainerPaths() throws IOException { + String yaml = resource("openapi/overseas-business-v1.yaml"); + String prefix = "/api/pda/v1/last-leg/receiving-containers"; + assertTrue(yaml.contains(prefix + "/candidates:")); + assertTrue(yaml.contains(prefix + "/warehouse-profile:")); + assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); + assertFalse(yaml.contains(prefix + "/current/skus/resolve:")); + assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); + assertTrue(yaml.contains(prefix + "/current/complete:")); + assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:")); + assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}")); + String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); + List successResponses = receivingPaths.lines().map(String::trim) + .filter(line -> line.startsWith("responses: { '200':")).toList(); + assertEquals(16, successResponses.size()); + assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); + assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); + assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); + assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); + assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取")); + assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]")); + assertTrue(yaml.contains("photos: { type: array, minItems: 3, maxItems: 3")); + assertTrue(yaml.contains("required: [role, fileId, downloadPath]")); + assertTrue(yaml.contains("overseasWarehouseId: { type: string")); + assertTrue(yaml.contains("overseasWarehouseName: { type: string")); + assertFalse(receivingPaths.contains("photoFileIds")); + assertFalse(receivingPaths.contains("skuCode")); + assertFalse(yaml.contains("ReceivingSkuResolved")); + assertFalse(Arrays.stream(ReceivingContainerRequests.class.getDeclaredClasses()) + .anyMatch(type -> "SkuResolve".equals(type.getSimpleName()))); + } + + private String resource(String name) throws IOException { + try (var input = getClass().getClassLoader().getResourceAsStream(name)) { + assertTrue(input != null, name); + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static String tableBlock(String schema, String table) { + int start = schema.indexOf("create table if not exists " + table + " ("); + int end = schema.indexOf(") engine=innodb", start); + return start < 0 || end < 0 ? "" : schema.substring(start, end); + } + +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java new file mode 100644 index 000000000..2183d612e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -0,0 +1,118 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; +import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReceivingContainerControllerTest { + @Mock ReceivingContainerService service; + private ReceivingContainerController controller; + + @BeforeEach + void setUp() { + controller = new ReceivingContainerController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() { + when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate( + "CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false))); + + controller.candidates(picker()); + + verify(service).candidates("42", 900L); + } + + @Test + void warehouseProfileUsesOnlyTheTokenBoundWarehouse() { + when(service.warehouseProfile(900L)).thenReturn( + new ReceivingContainerResponses.WarehouseProfile("900", "US-LAX-01", "洛杉矶一号仓")); + + var response = controller.warehouseProfile(picker()); + + assertThat(response.data().warehouseName()).isEqualTo("洛杉矶一号仓"); + verify(service).warehouseProfile(900L); + } + + @Test + void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() { + AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB", + Set.of(ReceivingContainerController.OPERATE_PERMISSION)); + assertThatThrownBy(() -> controller.candidates(merchant)).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("401"); + } + + @Test + void platformIdentityWithoutPickerAuthorityIsForbidden() { + AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "admin", "PLATFORM", "WEB", Set.of()); + assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("403"); + } + + @Test + void downloadCurrentUnloadPhotoDelegatesToService() { + var user = picker(); + when(service.downloadCurrentPhoto("42", "9999")).thenReturn( + new UnloadPhotoFileGateway.DownloadedFile("abc".getBytes(), "photo.jpg", MediaType.IMAGE_JPEG_VALUE)); + + ResponseEntity response = controller.downloadCurrentPhoto(user, "9999"); + + assertThat(response.getStatusCodeValue()).isEqualTo(200); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.IMAGE_JPEG); + assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)) + .startsWith("inline").contains("photo.jpg"); + assertThat(response.getHeaders().getCacheControl()).isEqualTo("no-store"); + assertThat(response.getBody()).containsExactly("abc".getBytes()); + verify(service).downloadCurrentPhoto("42", "9999"); + } + + @Test + void pickerAuthorityWithoutWarehouseBindingIsForbidden() { + AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA", + Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION)); + assertThatThrownBy(() -> controller.candidates(unbound)).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("403"); + } + + @Test + void fbaCartonResolutionDelegatesWithoutControllerModeRejection() { + ReceivingContainerRequests.CartonResolve request = + new ReceivingContainerRequests.CartonResolve("NYEXX2608260001#4-1"); + when(service.resolveCarton("42", request)).thenReturn(new ReceivingContainerResponses.CartonResolved( + "NYEXX2608260001#4-1", "NYEXX2608260001", "FBA", "900", "Shared LAX", null, null, null, + "ABE3", 1, 4, 0, 4)); + + controller.resolveCarton(picker(), request); + + verify(service).resolveCarton("42", request); + } + + private static AuthenticatedUser picker() { + return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA", + Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION), + false, null, null, false, 900L); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java new file mode 100644 index 000000000..443d5fbee --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import org.springframework.web.server.ResponseStatusException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +class HttpUnloadPhotoFileGatewayTest { + private HttpUnloadPhotoFileGateway gateway; + private MockRestServiceServer server; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + gateway = new HttpUnloadPhotoFileGateway(); + ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://file-service").build()); + ReflectionTestUtils.setField(gateway, "internalToken", "internal-token"); + } + + @Test + void forgedImageBusinessFailureMapsToBadRequestInsteadOfInternalError() { + server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8")) + .andRespond(withSuccess("{\"code\":\"A0400\",\"message\":\"上传内容不是有效图片\",\"data\":null}", + MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> gateway.upload("100", 8L, 7L, + new MockMultipartFile("file", "forged.png", "image/png", "')), + }, 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', { + name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG, + }, token) + const fileId = id(response?.body?.data?.fileId) + context.addStep(MODULE, `上传${role}真实签收照片`, response, success(response) && longId(fileId), + `role=${role}, fileId=${fileId || 'missing'}; ${trace(response)}`, OPTIONS) + if (!longId(fileId)) return null + photos.push({ role, fileId }) + } + return photos +} + +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() + const lastResponses = new Map() + for (let attempt = 1; attempt <= 30; 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)}`) + return { containerNo, response } + })) + for (const { containerNo, response } of results) { + lastResponses.set(containerNo, response) + const rows = records(response?.body?.data) + if (success(response) && rows.some((item) => id(item?.containerNo) === containerNo)) { + consumed.add(containerNo) + } + } + if (expected.every((containerNo) => consumed.has(containerNo))) break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + for (const containerNo of expected) { + const response = lastResponses.get(containerNo) || null + context.addStep(MODULE, `跨区广播消费观察 ${containerNo}`, response, + true, + `containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OBSERVATION_OPTIONS) + } + return expected.every((containerNo) => consumed.has(containerNo)) +} + +async function awaitPickupReadyCandidates(context, token, producers, allProducers) { + const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] + const expectedSet = new Set(expected) + const awaiting = [...new Set(allProducers + .filter((item) => item?.shippingReady === false && !expectedSet.has(id(item?.containerNo))) + .map((item) => id(item?.containerNo)).filter(nonBlank))] + const known = new Set(allProducers.map((item) => id(item?.containerNo)).filter(nonBlank)) + if (!expected.length) return false + let response = null + let seen = new Set() + for (let attempt = 1; attempt <= 30; attempt += 1) { + response = await context.requestOverseas('GET', + '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) + const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] + seen = new Set(candidateRows.map((item) => id(item?.containerNo)).filter(nonBlank)) + if (expected.every((containerNo) => seen.has(containerNo)) + && awaiting.every((containerNo) => !seen.has(containerNo))) break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + const seenExpected = expected.filter((containerNo) => seen.has(containerNo)) + const missingPickupReady = expected.filter((containerNo) => !seen.has(containerNo)) + const unexpectedAwaiting = awaiting.filter((containerNo) => seen.has(containerNo)) + const unexpectedHistorical = [...seen].filter((containerNo) => !known.has(containerNo)) + const targetContainerNo = id(context.createdResources?.phase3Loading?.containerNo) + const carrierQuery = context.createdResources?.phase3CarrierScheduleQuery + const pickup = context.createdResources?.phase4CarrierPickupReady + const targetCorrelated = nonBlank(targetContainerNo) && expectedSet.has(targetContainerNo) + && seen.has(targetContainerNo) && carrierQuery?.marker === context.marker + && id(carrierQuery?.trackingNo) === targetContainerNo && pickup?.marker === context.marker + && pickup?.ready === true && id(pickup?.queryId) === id(carrierQuery?.queryId) + const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 && targetCorrelated + context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready, + `targetContainerNo=${targetContainerNo || 'missing'}, targetCorrelated=${targetCorrelated}, expected=${expected.join('|')}, awaiting=${awaiting.join('|') || 'none'}, seenExpected=${seenExpected.join('|') || 'none'}, missingPickupReady=${missingPickupReady.join('|') || 'none'}, unexpectedAwaiting=${unexpectedAwaiting.join('|') || 'none'}, unexpectedHistorical=${unexpectedHistorical.join('|') || 'none'}; ${trace(response)}`, OPTIONS) + return ready +} + +async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = []) { + const manifestCartons = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) + .map(id).filter(nonBlank) + const fbaCartons = inbounds.filter((item) => item.destinationType === 'FBA') + .map((item, index) => `${item.inboundCode}#PDA-CONTRACT-${index + 1}`) + const overseasCartons = inbounds.some((item) => item.destinationType === 'OVERSEAS') ? manifestCartons : [] + const cartonCandidates = [...new Set([...fbaCartons, ...overseasCartons])] + if (!cartonCandidates.length) { + blockerGap(context, '统一箱码解析链', 'FBA缺少当前柜入仓码或OVERSEAS缺少真实manifest箱码') + return null + } + 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 resolved = response.body?.data + const matches = inbounds.filter((item) => item.inboundCode === resolved?.inboundCode) + const destinations = new Set(matches.map((item) => item?.destinationType).filter(nonBlank)) + const unique = success(response) && nonBlank(resolved?.inboundCode) && !Array.isArray(resolved?.inboundCode) + && matches.length === 1 && destinations.size === 1 + && ['FBA', 'OVERSEAS'].includes(resolved?.destinationType) + && destinations.has(resolved?.destinationType) + context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response, + unique, `cartonMark=${cartonMark}, inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}; ${trace(response)}`, OPTIONS) + if (!unique) { + blockerGap(context, `箱码${cartonMark}唯一归属`, + `FBA和OVERSEAS箱码resolve都必须HTTP 200/code 200并返回当前柜唯一入仓码;inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}`, + 'fixture-mismatch', response) + return null + } + cartonFixtures.push({ cartonMark, inbound: matches[0], resolved }) + } + return cartonFixtures +} + +async function probeCartonResolveNegatives(context, token, current) { + const fba = current.inbounds.find((item) => item.destinationType === 'FBA') + await expected(context, token, 'POST', '无#箱码被服务端拒绝', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', + { cartonMark: fba?.inboundCode || `NO-HASH-${markerSuffix(context)}` }, cartonResolveRejected) + await expected(context, token, 'POST', '空#前缀箱码被服务端拒绝', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', + { cartonMark: `#${markerSuffix(context)}` }, cartonResolveRejected) + await expected(context, token, 'POST', '未命中入仓码前缀被服务端拒绝', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', + { cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected) + + 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) { + await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', + { cartonMark: crossContainerMark }, cartonResolveRejected) + } else { + blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证') + } + + const overseas = current.inbounds.find((item) => item.destinationType === 'OVERSEAS') + if (overseas) { + const total = Math.max(1, Number(overseas.totalPackages || 1)) + await expected(context, token, 'POST', 'OVERSEAS缺少真实manifest行时不得按标准箱码推导', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', + { cartonMark: `${overseas.inboundCode}#${total + 1}-1` }, + (response) => a0404Message(response, /^Carton mark was not found$/)) + } +} + +async function receiveFba(context, token, inbound, pallet, cartonFixtures, index) { + const resolvedCartons = cartonFixtures.filter((item) => item.inbound.inboundCode === inbound.inboundCode + && item.resolved.destinationType === 'FBA') + if (!resolvedCartons.length) { + blockerGap(context, `FBA入仓码${inbound.inboundCode}箱码解析`, '没有冻结箱码以200/code 200唯一解析到当前FBA入仓码') + return false + } + const packageCount = Number(inbound.pendingPackages || 0) + if (packageCount <= 0) return true + const body = { inboundCode: inbound.inboundCode, palletCode: pallet.palletCode, + packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) } + const first = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}按箱数实收`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + if (!success(first.response)) return false + const replay = await pda(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键同载荷重放`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + context.assert(MODULE, `FBA入仓码${inbound.inboundCode}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}同键异载荷冲突`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { ...body, packageCount: packageCount + 1 }, conflict) + await expected(context, token, 'POST', `FBA入仓码${inbound.inboundCode}超收被拒绝`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict) + return true +} + +async function receiveOverseas(context, token, inbounds, pallets, cartonFixtures) { + const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0) + const overseasFixtures = cartonFixtures.filter((item) => item.resolved.destinationType === 'OVERSEAS' + && inbounds.some((inbound) => inbound.inboundCode === item.inbound.inboundCode)) + .map((item) => ({ cartonMark: item.cartonMark, pallet: pallets.get(item.inbound.inboundCode) })) + const fixturesConserved = overseasFixtures.length === expectedPending && overseasFixtures.every((item) => item.pallet) + context.assert(MODULE, 'OVERSEAS实装箱唛与待收箱数守恒', null, fixturesConserved, + `resolvedFixtures=${overseasFixtures.length}, pendingPackages=${expectedPending}`, OPTIONS) + if (!fixturesConserved) { + blockerGap(context, 'OVERSEAS逐箱冻结事实', + `统一解析结果未完整覆盖本柜真实冻结箱码;resolvedFixtures=${overseasFixtures.length}, pendingPackages=${expectedPending}`, + 'fixture-mismatch') + return false + } + for (const [index, fixture] of overseasFixtures.entries()) { + const { cartonMark, pallet } = fixture + const body = { cartonMark, palletCode: pallet.palletCode, + idempotencyKey: key(context, `overseas-carton-${index}`) } + const first = await pda(context, token, 'POST', `OVERSEAS逐箱实收${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + if (!success(first.response)) return false + const replay = await pda(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键同载荷重放`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + context.assert(MODULE, `OVERSEAS逐箱${index + 1}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键异载荷冲突`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict) + await expected(context, token, 'POST', `OVERSEAS重复箱新键被拒绝${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, idempotencyKey: key(context, `overseas-duplicate-${index}`) }, conflict) + } + return true +} + +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) + } + 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 before = await context.requestOverseas('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) + if (success(before)) { + 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 candidate = preferredContainer + ? candidates.data.find((item) => item.containerNo === preferredContainer) + : candidates.data[0] + if (!candidate) { + blockerGap(context, `本轮柜号${preferredContainer || 'missing'}候选`, + '三期装柜完成生产者已落地,但海外候选尚未出现该柜;禁止回退处理历史或其他柜', 'producer-chain', candidates.response) + return + } + const containerNo = id(candidate?.containerNo) + context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response, + nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0, + `containerNo=${containerNo || 'missing'}, total=${candidate?.totalPackages ?? 'missing'}; ${trace(candidates.response)}`, OPTIONS) + if (!containerNo) return + context.createdResources.phase4OverseasPda.containerNo = containerNo + const detail = await pda(context, token, 'GET', '查询柜号完整入仓码详情', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}`) + if (!Array.isArray(detail.data?.inbounds) || detail.data.inbounds.length === 0) return + const prepareBody = { idempotencyKey: key(context, 'prepare') } + const prepared = await pda(context, token, 'POST', '选择柜号并建立WAIT_SIGN会话', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) + if (!success(prepared.response)) return + const prepareReplay = await pda(context, token, 'POST', '选择柜号同键重放', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) + context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response, + success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS) + if (secondaryToken) { + await expected(context, secondaryToken, 'POST', '第二仓库操作员抢占同柜返回409', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, + { idempotencyKey: key(context, 'secondary-prepare') }, conflict) + } + const other = candidates.data.find((item) => item.containerNo !== containerNo) + if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict) + else blockerGap(context, '跨柜锁409', '当前客户只有一个待收柜,无法在不伪造柜数据的前提下验证跨柜锁') + const photos = await uploadPhotos(context, token) + if (!photos) return + const signBody = { photos, idempotencyKey: key(context, 'sign') } + const signed = await pda(context, token, 'POST', '绑定车牌封签开柜三角色照片并签收', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody) + if (!success(signed.response)) return + const signReplay = await pda(context, token, 'POST', '签收同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody) + context.assert(MODULE, '三角色照片各一次且签收幂等', signReplay.response, + success(signReplay.response) && signReplay.data?.status === 'SIGNED' + && PHOTO_ROLES.every((role) => signReplay.data?.photos?.filter((item) => item.role === role).length === 1), trace(signReplay.response), OPTIONS) + await expected(context, token, 'POST', '签收同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/sign', + { ...signBody, photos: [...photos].reverse() }, conflict) + 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) + const producerCartonMarks = preferredProducer?.cartonMarks?.length + ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark] + const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks) + if (!cartonFixtures) return + const afterResolve = await pda(context, token, 'GET', '箱码解析正负向后回查当前柜零写入', + '/api/pda/v1/last-leg/receiving-containers/current') + context.assert(MODULE, 'FBA与OVERSEAS箱码resolve均为纯查询且零状态写入', afterResolve.response, + success(afterResolve.response) && receivingSnapshot(afterResolve.data) === beforeResolve, + `before=${beforeResolve}, after=${receivingSnapshot(afterResolve.data)}; ${trace(afterResolve.response)}`, OPTIONS) + if (!success(afterResolve.response) || receivingSnapshot(afterResolve.data) !== beforeResolve) return + const pallets = new Map() + for (const [index, inbound] of current.data.inbounds.entries()) { + const pallet = await createPallet(context, inbound, index) + if (!pallet) { + blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`, + `无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource') + continue + } + pallets.set(inbound.inboundCode, pallet) + context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId) + const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`, + '/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode }) + context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response, + success(resolved.response) && resolved.data?.compatible === true && resolved.data?.destinationType === inbound.destinationType, trace(resolved.response), OPTIONS) + } + const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA') + const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS') + if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { + const firstFba = fbaInbounds[0] + const fbaCarton = cartonFixtures.find((item) => item.resolved.destinationType === 'FBA' + && item.inbound.inboundCode === firstFba.inboundCode) + if (!fbaCarton) { + blockerGap(context, `FBA入仓码${firstFba.inboundCode}箱码反证`, '没有冻结FBA箱码可用于验证carton收货精确A0409') + return + } + await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { + cartonMark: fbaCarton?.cartonMark, + palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), + }, (response) => a0409Message(response, /^FBA destination must use bulk count$/)) + for (const [index, inbound] of fbaInbounds.entries()) { + await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), cartonFixtures, index) + } + } else if (preferredProducer?.destinationType !== 'OVERSEAS') { + blockerGap(context, 'FBA按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') + } + if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) { + const firstOverseas = overseasInbounds[0] + const pallet = pallets.get(firstOverseas.inboundCode) + await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { + inboundCode: firstOverseas.inboundCode, palletCode: pallet.palletCode, + packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), + }, (response) => a0409Message(response, /^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden$/)) + await receiveOverseas(context, token, overseasInbounds, pallets, cartonFixtures) + } else if (preferredProducer?.destinationType !== 'FBA') { + blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + } + const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') + if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { + blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response) + return + } + const completeBody = { idempotencyKey: key(context, 'complete') } + const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) + 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) + 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') { + const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) + for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) { + const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode, + pallets.get(inbound.inboundCode), inbound.destinationType) + if (warehouseFacts && context.context?.phase4OverseasPdaOperations) { + context.context.phase4OverseasPdaOperations.completedFixtures.push({ + containerNo, order, ...warehouseFacts, + }) + } + } + } +} + +export async function run(context) { + context.createdResources.phase4OverseasPda = { + marker: context.marker, containerNo: null, customerId: null, orderId: null, + palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [], + overseasWarehouseId: null, overseasWarehouseName: null, + cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', + } + const merchantToken = await loginMerchantForNegativeProbe(context) + const pickerAccess = await resolvePickerAccess(context) + if (!pickerAccess) return + const sharedWarehouse = await resolveSharedWarehouse(context) + if (!sharedWarehouse) { + blockerGap(context, '海外PDA共享实际收货仓', '国内Warehouse主数据没有ACTIVE且启用RECEIVING的OVERSEAS固定作业仓') + return + } + context.createdResources.phase4OverseasPda.overseasWarehouseId = sharedWarehouse.id + context.createdResources.phase4OverseasPda.overseasWarehouseName = sharedWarehouse.name + const primary = await createPicker(context, 1, sharedWarehouse.id, pickerAccess) + const secondary = primary?.token ? await createPicker(context, 2, sharedWarehouse.id, pickerAccess) : null + for (const picker of [primary, secondary]) if (picker?.membershipId) { + context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId) + } + let handoffReady = false + const cleanup = async () => { + await disablePicker(context, secondary, 2) + await disablePicker(context, primary, 1) + } + 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, + overseasWarehouseId: sharedWarehouse.id, + completedFixtures: [], + cleanup, + } + try { + if (!primary?.token || !secondary?.token) { + blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁') + return + } + const matrix = context.createdResources?.phase4InboundProducerFixtures + const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] + .filter((item) => nonBlank(item?.containerNo)) + const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers + if (allProducers.length) await awaitProducerConsumption(context, allProducers) + if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) { + blockerGap(context, '实际处理柜PICKUP_READY门禁', + 'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜') + return + } + for (const [index, producer] of (producers.length ? producers : [null]).entries()) { + context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}` + context.createdResources.phase4OverseasPda.finalStatus = null + await runReceiving(context, primary.token, secondary.token, + index === 0 ? merchantToken : null, producer, index === 0) + if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { + context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null) + } + } + handoffReady = context.context.phase4OverseasPdaOperations.completedFixtures.length > 0 + } finally { + delete context.pdaContainerSuffix + if (!handoffReady) { + try { await cleanup() } + finally { delete context.context.phase4OverseasPdaOperations } + } + } +} + +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) + const fixedIds = new Set(['193000000000200003', '193000000000200004']) + const warehouse = success(response) ? rows.find((item) => fixedIds.has(id(item?.id))) : null + context.addStep(MODULE, '读取国内Warehouse权威海外PDA实际收货仓', response, + Boolean(warehouse) && warehouse?.warehouseType === 'OVERSEAS' && warehouse?.status === 'ACTIVE', + `warehouseId=${id(warehouse?.id) || 'missing'}, warehouseName=${warehouse?.warehouseName || warehouse?.name || 'missing'}; ${trace(response)}`, OPTIONS) + return warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : 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 new file mode 100644 index 000000000..261b0e84b --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { run } from './phase4-overseas-warehouse.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), 'utf8') + +function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } +function voidResponse() { return { http: 200, body: { code: '200', message: 'success' }, requestId: 'request-test', traceId: 'trace-test' } } +function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } +function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { + const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null + const context = { + context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, + async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) }, + async requestOverseas(method, requestPath, body) { + calls.push({ channel: 'overseas', method, path: requestPath, body }) + if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') + if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) + 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 (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409') + createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse() + } + if (method === 'POST' && requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) { + if (createdRelabel && body.remark !== createdRelabel.remark) return response(null, 'A0409') + if (!createdRelabel) createdRelabel = { ...body, relabelNo: 'FBR-TEST', status: 'PENDING_PROCESSING' } + return response({ application: createdRelabel, items: body.items, history: [] }) + } + if (method === 'PUT' && requestPath.endsWith('/relabel-applications/FBR-TEST/status')) { + createdRelabel = { ...createdRelabel, status: body.status } + return response({ application: createdRelabel, items: createdRelabel.items, history: [] }) + } + if (requestPath.startsWith('/api/platform/v1/last-leg/orders?')) return response(page([{ id: '30001', orderNo: 'LL-1', currentStatus: 'PENDING_ASSIGNMENT' }])) + if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] }) + if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }])) + if (requestPath.includes('/relabel-applications/RL-1?')) return response({ application: { relabelNo: 'RL-1' }, items: [], history: [] }) + if (requestPath.startsWith('/api/platform/v1/last-leg/inbound-plans?')) return response(page([{ lineId: '50002', planId: '50001', orderId: '30001', planNo: 'IP-1', customerId: '10001', status: 'PENDING_ASSIGNMENT' }])) + if (requestPath.includes('/inbound-plans/IP-1?')) return response({ planNo: 'IP-1', sourceOrderId: '30001', lines: [], pallets: [], allocations: [] }) + if (requestPath.includes('/inventory/summary?')) return response({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 }) + 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.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) + if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } }) + if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', 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 + ? 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) + 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' } }, + 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 }) }, + addCoverageGap(...args) { gaps.push(args) }, + } + return context +} + +test('模块只依赖注入请求,不读取环境或自行联网', () => { + assert.match(source, /export async function run\(context\)/) + assert.match(source, /context\.requestOverseas\('GET'/) + assert.match(source, /context\.requestOverseasBinary\('GET'/) + assert.doesNotMatch(source, /https?:\/\//) + assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/) +}) + +test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { + const context = contextFixture() + return run(context).then(() => { + assert.ok(context.calls.length >= 12) + assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method))) + assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1) + 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('/relabel-applications'))) + assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/') || call.path.includes('/relabel-applications'))) + for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) { + const step = context.steps.find((item) => item.name === name) + assert.ok(step?.passed, name) + assert.equal(Object.hasOwn(step.result?.body || {}, 'data'), false, name) + } + const deletedReadback = context.steps.find((step) => step.name === '本轮公共库位已删除且回查不存在') + assert.equal(deletedReadback?.result?.body?.code, 'A0404') + assert.ok(deletedReadback?.passed) + assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) + }) +}) + +test('覆盖冻结的海外只读列表与有记录才查详情', async () => { + const context = contextFixture() + await run(context) + for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/locations?', '/pallets?']) { + assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken) + } + for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/warehouse-admin/20001?', '/pallets/70001']) { + assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken) + } + for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) { + const params = new URL(`http://acceptance${call.path}`).searchParams + assert.equal(params.has('page'), false, call.path) + assert.equal(params.has('size'), false, call.path) + } + assert.deepEqual(context.context.phase4OverseasWarehouse, { customerId: '10001', warehouseId: '20001', palletId: '71001', relabelNo: 'FBR-TEST' }) + assert.deepEqual(context.createdResources.phase4OverseasWarehouse, { + marker: 'ACC-PHASE4-TEST', customerId: '10001', warehouseId: '20001', palletId: '71001', terminalStatus: 'SCRAPPED', + locationId: '72002', locationTerminalStatus: 'DELETED', relabelNo: 'FBR-TEST', relabelTerminalStatus: 'COMPLETED', + }) +}) + +test('缺少客户时明确失败,缺少仓库时通过正式接口创建并停用fixture', async () => { + const noCustomer = contextFixture({ tenants: [] }) + await run(noCustomer) + assert.equal(noCustomer.gaps.length, 0) + assert.ok(noCustomer.steps.some((step) => step.name === '海外仓验收必须取得ACTIVE客户' && !step.passed)) + assert.equal(noCustomer.calls.filter((call) => call.channel === 'overseas').length, 0) + + const noWarehouse = contextFixture({ warehouseRecords: [] }) + await run(noWarehouse) + assert.equal(noWarehouse.gaps.some((gap) => gap[2] === '公共托盘真实仓库前置'), false) + assert.ok(noWarehouse.calls.some((call) => call.method === 'POST' && call.path === '/api/platform/v1/warehouse-admin')) + assert.ok(noWarehouse.calls.some((call) => call.method === 'PATCH' && call.path.includes('/warehouse-admin/20001/status?'))) + assert.ok(noWarehouse.calls.some((call) => call.path.includes('/pallets'))) +}) + +test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', () => { + assert.match(source, /inventory\/balances\/export/) + assert.match(source, /inventory\/ledger\/export/) + assert.match(source, /inventory\/summary[^\n]+\{ customerId \}/) + assert.match(source, /inventory\/balances\/export[^\n]+\{ customerId \}/) + assert.match(source, /inventory\/ledger\/export[^\n]+\{ customerId \}/) + for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden) + assert.match(source, /requestId=/) + assert.match(source, /traceId=/) + for (const field of ['lineId', 'planId', 'orderId']) { + assert.match(source, new RegExp(`longId\\(plan\\.${field}\\)`)) + } + assert.match(source, /id\(pallet\.customerId\) === '0'/) + assert.match(source, /创建本轮换标单/) + assert.match(source, /换标单同幂等键不同请求返回A0409/) + assert.match(source, /创建本轮公共库位/) + assert.match(source, /公共托盘旧版本编辑返回冲突/) + assert.doesNotMatch(source, /deploymentBlocker|addCoverageGap|coverageGap/) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs new file mode 100644 index 000000000..7dc90a1b3 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -0,0 +1,422 @@ +const MODULE = 'phase4-overseas-warehouse' +const SCENARIO = 'phase4-overseas-warehouse' +const OPTIONS = { scenario: SCENARIO } + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } +function nonNegative(value) { return Number.isFinite(Number(value)) && Number(value) >= 0 } +function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, params) { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value)) + return `${path}?${search}` +} +function records(page) { return Array.isArray(page?.records) ? page.records : [] } +function pageValid(page) { + return Array.isArray(page?.records) && Number.isInteger(Number(page?.page)) && Number(page.page) >= 1 + && Number.isInteger(Number(page?.size)) && Number(page.size) >= 1 + && nonNegative(page?.total) && nonNegative(page?.pages) +} +function assertEnvelope(context, name, response) { + context.assert(MODULE, `${name}响应包络正确`, response, + response?.http === 200 && String(response?.body?.code) === '200' && Object.hasOwn(response?.body || {}, 'data'), + trace(response), OPTIONS) +} +async function domesticGet(context, name, 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 overseasGet(context, name, path) { + const response = await context.requestOverseas('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 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 } +} +async function overseasCsv(context, name, path) { + const response = await context.requestOverseasBinary('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) + return response +} +function assertPage(context, name, result) { + context.assert(MODULE, `${name}分页结构正确`, result.response, pageValid(result.data), + `records=${result.data?.records?.length ?? 'missing'}, page=${result.data?.page ?? 'missing'}, size=${result.data?.size ?? 'missing'}, total=${result.data?.total ?? 'missing'}; ${trace(result.response)}`, OPTIONS) +} +function businessConflict(response) { + return response?.http === 409 || (response?.http === 200 && String(response?.body?.code) === 'A0409') +} +function voidMutationSucceeded(result) { + return result?.response?.http === 200 && String(result?.response?.body?.code) === '200' + && result.data == null +} +function businessNotFound(response) { + return response?.http === 404 || (response?.http === 200 && String(response?.body?.code) === 'A0404') +} + +export async function run(context) { + context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null } + const tenantsResult = await domesticGet(context, '查询可用客户候选', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE') + assertPage(context, '可用客户候选', tenantsResult) + const customer = records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE') + const customerId = id(customer?.id) + if (!customerId) { + context.assert(MODULE, '海外仓验收必须取得ACTIVE客户', tenantsResult.response, false, + `IAM ACTIVE 客户列表未返回可用 customerId;${trace(tenantsResult.response)}`, OPTIONS) + return + } + + const scoped = { customerId, page: 1, size: 20 } + const orders = await overseasGet(context, '查询尾程订单列表', query('/api/platform/v1/last-leg/orders', scoped)) + assertPage(context, '尾程订单列表', orders) + const order = records(orders.data)[0] + if (order) { + context.assert(MODULE, '尾程订单关键字段正确', orders.response, + longId(order.id) && nonBlank(order.orderNo) && nonBlank(order.currentStatus), + `id=${id(order.id) || 'missing'}, orderNo=${order.orderNo || 'missing'}, status=${order.currentStatus || 'missing'}; ${trace(orders.response)}`, OPTIONS) + const detail = await overseasGet(context, '查询尾程订单详情', query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}`, { customerId })) + context.assert(MODULE, '尾程订单详情对应列表记录', detail.response, + id(detail.data?.order?.id) === id(order.id) && Array.isArray(detail.data?.cargo) && Array.isArray(detail.data?.statusHistory), + `listId=${id(order.id)}, detailId=${id(detail.data?.order?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + } + + const relabels = await overseasGet(context, '查询换标单列表', query('/api/platform/v1/last-leg/relabel-applications', scoped)) + assertPage(context, '换标单列表', relabels) + const relabel = records(relabels.data)[0] + if (relabel) { + context.assert(MODULE, '换标单关键字段正确', relabels.response, + longId(relabel.id) && nonBlank(relabel.relabelNo) && nonBlank(relabel.status), + `id=${id(relabel.id) || 'missing'}, relabelNo=${relabel.relabelNo || 'missing'}, status=${relabel.status || 'missing'}; ${trace(relabels.response)}`, OPTIONS) + const detail = await overseasGet(context, '查询换标单详情', query(`/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabel.relabelNo)}`, { customerId })) + context.assert(MODULE, '换标单详情对应列表记录', detail.response, + detail.data?.application?.relabelNo === relabel.relabelNo && Array.isArray(detail.data?.items) && Array.isArray(detail.data?.history), + `listNo=${relabel.relabelNo}, detailNo=${detail.data?.application?.relabelNo || 'missing'}; ${trace(detail.response)}`, OPTIONS) + } + + const plans = await overseasGet(context, '查询入仓计划列表', query('/api/platform/v1/last-leg/inbound-plans', scoped)) + assertPage(context, '入仓计划列表', plans) + const plan = records(plans.data)[0] + if (plan) { + context.assert(MODULE, '入仓计划关键字段正确', plans.response, + longId(plan.lineId) && longId(plan.planId) && longId(plan.orderId) + && nonBlank(plan.planNo) && id(plan.customerId) === customerId && nonBlank(plan.status), + `lineId=${id(plan.lineId) || 'missing'}, planId=${id(plan.planId) || 'missing'}, orderId=${id(plan.orderId) || 'missing'}, planNo=${plan.planNo || 'missing'}, status=${plan.status || 'missing'}; ${trace(plans.response)}`, OPTIONS) + const detail = await overseasGet(context, '查询入仓计划详情', query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}`, { customerId })) + context.assert(MODULE, '入仓计划详情对应列表记录', detail.response, + detail.data?.planNo === plan.planNo && longId(detail.data?.sourceOrderId) + && Array.isArray(detail.data?.lines) && Array.isArray(detail.data?.pallets) && Array.isArray(detail.data?.allocations), + `listNo=${plan.planNo}, detailNo=${detail.data?.planNo || 'missing'}, sourceOrderId=${id(detail.data?.sourceOrderId) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + } + + const summary = await overseasGet(context, '查询库存汇总(不传分页参数)', query('/api/platform/v1/last-leg/inventory/summary', { customerId })) + context.assert(MODULE, '库存汇总关键数量非负', summary.response, + ['inboundOrderCount', 'palletCount', 'totalPackages', 'availablePackages', 'holdPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages'] + .every((field) => nonNegative(summary.data?.[field])), + `total=${summary.data?.totalPackages ?? 'missing'}, available=${summary.data?.availablePackages ?? 'missing'}; ${trace(summary.response)}`, OPTIONS) + + const balances = await overseasGet(context, '查询SKU库存余额', query('/api/platform/v1/last-leg/inventory/balances', scoped)) + assertPage(context, 'SKU库存余额', balances) + const balance = records(balances.data)[0] + if (balance) { + context.assert(MODULE, 'SKU库存余额关键字段正确', balances.response, + longId(balance.id) && id(balance.customerId) === customerId && nonBlank(balance.skuCode) + && nonNegative(balance.totalPackages) && nonNegative(balance.availablePackages) && nonBlank(balance.updatedAt), + `id=${id(balance.id) || 'missing'}, sku=${balance.skuCode || 'missing'}, total=${balance.totalPackages ?? 'missing'}; ${trace(balances.response)}`, OPTIONS) + const positions = await overseasGet(context, '查询库存库位详情', query(`/api/platform/v1/last-leg/inventory/balances/${encodeURIComponent(id(balance.id))}/positions`, { customerId })) + context.assert(MODULE, '库存库位详情结构正确', positions.response, + Array.isArray(positions.data) && positions.data.every((item) => longId(item?.id) && id(item?.balanceId) === id(balance.id) && nonNegative(item?.totalPackages)), + `balanceId=${id(balance.id)}, positions=${positions.data?.length ?? 'missing'}; ${trace(positions.response)}`, OPTIONS) + } + + const ledger = await overseasGet(context, '查询进销存流水', query('/api/platform/v1/last-leg/inventory/ledger', scoped)) + assertPage(context, '进销存流水', ledger) + const ledgerRow = records(ledger.data)[0] + if (ledgerRow) context.assert(MODULE, '进销存流水关键字段正确', ledger.response, + longId(ledgerRow.id) && id(ledgerRow.customerId) === customerId && nonBlank(ledgerRow.businessType) + && Number.isFinite(Number(ledgerRow.beforePackages)) && Number.isFinite(Number(ledgerRow.deltaPackages)) + && Number.isFinite(Number(ledgerRow.afterPackages)) && nonBlank(ledgerRow.occurredAt), + `id=${id(ledgerRow.id) || 'missing'}, type=${ledgerRow.businessType || 'missing'}, delta=${ledgerRow.deltaPackages ?? 'missing'}; ${trace(ledger.response)}`, OPTIONS) + await overseasCsv(context, '下载SKU库存余额CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/balances/export', { customerId })) + await overseasCsv(context, '下载进销存流水CSV(不传分页参数)', query('/api/platform/v1/last-leg/inventory/ledger/export', { customerId })) + + let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId) + let ownedWarehouse = false + if (!warehouse) { + warehouse = await createWarehouseFixture(context, customerId) + ownedWarehouse = Boolean(warehouse) + } + if (!warehouse) return + 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 })) + assertPage(context, '公共库位列表', locations) + const location = records(locations.data)[0] + if (location) { + context.assert(MODULE, '公共库位关键字段正确', locations.response, + 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))}`) + 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 })) + assertPage(context, '公共托盘列表', pallets) + const pallet = records(pallets.data)[0] + if (pallet) { + context.assert(MODULE, '公共托盘关键字段正确', pallets.response, + 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))}`) + 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) + } + + try { + await verifyLocationLifecycle(context, warehouseId) + await verifyPalletLifecycle(context, warehouseId) + await verifyRelabelLifecycle(context, customerId, warehouseId) + context.context.phase4OverseasWarehouse = { + customerId, warehouseId, + palletId: context.createdResources.phase4OverseasWarehouse.palletId, + relabelNo: context.createdResources.phase4OverseasWarehouse.relabelNo, + } + } finally { + if (ownedWarehouse) await retireWarehouseFixture(context, customerId, warehouseId) + } +} + +async function verifyLocationLifecycle(context, warehouseId) { + const marker = String(context.marker || '').replace(/[^A-Za-z0-9_-]/g, '').slice(-40).toUpperCase() || 'PHASE4' + const locationCode = `ACC-${marker}`.slice(0, 64) + let locationId = '' + try { + const created = await overseasMutation(context, 'POST', '创建本轮公共库位', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { + zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1000, + status: 'ACTIVE', remark: context.marker, + }) + locationId = id(created.data) + context.createdResources.phase4OverseasWarehouse.locationId = locationId || null + 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 detail = await overseasGet(context, '查询本轮公共库位详情', detailPath) + context.assert(MODULE, '本轮公共库位创建事实正确', detail.response, + id(detail.data?.id) === locationId && id(detail.data?.customerId) === '0' + && detail.data?.locationCode === locationCode && detail.data?.remark === context.marker, + `locationId=${id(detail.data?.id) || 'missing'}, code=${detail.data?.locationCode || 'missing'}; ${trace(detail.response)}`, OPTIONS) + const updated = await overseasMutation(context, 'PUT', '编辑本轮公共库位', detailPath, { + zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1200, + status: 'ACTIVE', remark: `${context.marker}-edited`, + }) + context.assert(MODULE, '公共库位编辑成功', updated.response, voidMutationSucceeded(updated), + `locationId=${locationId}; ${trace(updated.response)}`, OPTIONS) + const after = await overseasGet(context, '回查公共库位编辑结果', detailPath) + context.assert(MODULE, '公共库位编辑字段已生效', after.response, + Number(after.data?.maxLoadKg) === 1200 && after.data?.remark === `${context.marker}-edited`, + `maxLoadKg=${after.data?.maxLoadKg ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS) + } 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)}`) + const deleted = voidMutationSucceeded(removed) && businessNotFound(deletedDetail) + context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = deleted ? 'DELETED' : null + context.assert(MODULE, '本轮公共库位已删除且回查不存在', deletedDetail, deleted, + `locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; delete=${trace(removed.response)}, readback=${trace(deletedDetail)}`, OPTIONS) + } + } +} + +async function verifyRelabelLifecycle(context, customerId, warehouseId) { + const marker = String(context.marker || 'PHASE4') + const idempotencyKey = `${marker}:RELABEL`.slice(0, 255) + const skuSuffix = marker.replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() || 'PHASE4' + const payload = { + customerOrderNo: `${marker}-RELABEL`, overseasWarehouseId: warehouseId, + labelDeadline: new Date(Date.now() + 86400000).toISOString().slice(0, 19), + contactName: 'Phase4 QA', contactPhone: '+1-213-555-0199', remark: marker, idempotencyKey, + items: [{ skuCode: `OLD-${skuSuffix}`.slice(0, 128), productNameZh: `${marker} 换标商品`, + totalPackageCount: 2, notifiedPackageCount: 2, shipmentNo: `SHIP-${skuSuffix}`.slice(0, 128), + newSkuCode: `NEW-${skuSuffix}`.slice(0, 128), labelRequirement: marker }], + } + let relabelNo = '' + let terminalStatus = '' + try { + const created = await overseasMutation(context, 'POST', '创建本轮换标单', + query('/api/platform/v1/last-leg/relabel-applications', { customerId }), payload) + relabelNo = id(created.data?.application?.relabelNo) + context.createdResources.phase4OverseasWarehouse.relabelNo = relabelNo || null + context.assert(MODULE, '换标单创建事实正确', created.response, + nonBlank(relabelNo) && created.data?.application?.customerOrderNo === payload.customerOrderNo + && created.data?.application?.status === 'PENDING_PROCESSING' + && Array.isArray(created.data?.items) && created.data.items.length === 1, + `relabelNo=${relabelNo || 'missing'}, status=${created.data?.application?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!relabelNo) return + + const replay = await overseasMutation(context, 'POST', '换标单幂等精确重放', + query('/api/platform/v1/last-leg/relabel-applications', { customerId }), payload) + context.assert(MODULE, '换标单精确重放返回同一单号', replay.response, + replay.data?.application?.relabelNo === relabelNo, + `expected=${relabelNo}, actual=${replay.data?.application?.relabelNo || 'missing'}; ${trace(replay.response)}`, OPTIONS) + + const conflict = await context.requestOverseas('POST', + query('/api/platform/v1/last-leg/relabel-applications', { customerId }), { ...payload, remark: `${marker}-DIFFERENT` }) + context.addStep(MODULE, '换标单同幂等键不同请求返回A0409', conflict, businessConflict(conflict), trace(conflict), OPTIONS) + + const processing = await overseasMutation(context, 'PUT', '换标单进入处理中', + `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, + { status: 'PROCESSING', remark: marker }) + context.assert(MODULE, '换标单状态推进为PROCESSING', processing.response, + processing.data?.application?.status === 'PROCESSING', + `status=${processing.data?.application?.status || 'missing'}; ${trace(processing.response)}`, OPTIONS) + const completed = await overseasMutation(context, 'PUT', '完成本轮换标单', + `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, + { status: 'COMPLETED', remark: `${marker}-completed` }) + terminalStatus = completed.data?.application?.status || '' + context.createdResources.phase4OverseasWarehouse.relabelTerminalStatus = terminalStatus || null + context.assert(MODULE, '本轮换标单已完成收口', completed.response, terminalStatus === 'COMPLETED', + `relabelNo=${relabelNo}, status=${terminalStatus || 'missing'}; ${trace(completed.response)}`, OPTIONS) + } finally { + if (relabelNo && terminalStatus !== 'COMPLETED') { + const cancelled = await context.requestOverseas('PUT', + `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, + { status: 'CANCELLED', remark: `${marker}-acceptance-cleanup` }) + const status = cancelled?.body?.data?.application?.status + context.createdResources.phase4OverseasWarehouse.relabelTerminalStatus = status || null + context.addStep(MODULE, '异常路径换标单取消收口', cancelled, + cancelled?.http === 200 && String(cancelled?.body?.code) === '200' && status === 'CANCELLED', trace(cancelled), OPTIONS) + } + } +} + +async function createWarehouseFixture(context, customerId) { + const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() + const payload = { + customerId, warehouseCode: `P4W-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 海外仓`, + warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', + contactName: 'Phase4 QA', contactPhone: '+1-213-555-0188', contactEmail: null, + status: 'ACTIVE', remark: `${context.marker}-warehouse-fixture`, + address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', + district: 'Commerce', postalCode: '90040', addressLine1: '501 Phase Four Warehouse Avenue', + addressLine2: null, latitude: null, longitude: null }, + capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, + cutoffTime: null, description: `${context.marker} acceptance` }], + } + const created = await overseasMutation(context, 'POST', '创建本轮客户海外仓', '/api/platform/v1/warehouse-admin', payload) + const warehouseId = id(created.data) + context.assert(MODULE, '客户海外仓创建返回长ID', created.response, longId(warehouseId), + `warehouseId=${warehouseId || 'missing'}, customerId=${customerId}; ${trace(created.response)}`, OPTIONS) + if (!warehouseId) return null + const detail = await overseasGet(context, '查询本轮客户海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}`, { customerId })) + context.assert(MODULE, '本轮客户海外仓归属和状态正确', detail.response, + id(detail.data?.warehouse?.id || detail.data?.id) === warehouseId + && id(detail.data?.warehouse?.customerId || detail.data?.customerId) === customerId + && (detail.data?.warehouse?.status || detail.data?.status) === 'ACTIVE', + `warehouseId=${warehouseId}, customerId=${id(detail.data?.warehouse?.customerId || detail.data?.customerId) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + return { id: warehouseId, customerId, warehouseCode: payload.warehouseCode, warehouseName: payload.warehouseName, status: 'ACTIVE' } +} + +async function retireWarehouseFixture(context, customerId, warehouseId) { + const retired = await context.requestOverseas('PATCH', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/status`, { customerId, status: 'INACTIVE' })) + context.addStep(MODULE, '本轮客户海外仓已停用收口', retired, + retired?.http === 200 && String(retired?.body?.code) === '200', trace(retired), OPTIONS) +} + +async function verifyPalletLifecycle(context, warehouseId) { + const marker = String(context.marker || '').replace(/[^A-Za-z0-9_-]/g, '').slice(-48) || 'PHASE4' + const palletCode = `ACC-${marker}`.slice(0, 128) + let palletId = '' + try { + const created = await overseasMutation(context, 'POST', '创建本轮公共托盘', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { + palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: context.marker, + }) + palletId = id(created.data) + context.createdResources.phase4OverseasWarehouse.palletId = palletId || null + context.assert(MODULE, '公共托盘创建返回长ID', created.response, longId(palletId), + `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 detail = await overseasGet(context, '查询本轮公共托盘详情', detailPath) + context.assert(MODULE, '本轮公共托盘创建事实正确', detail.response, + id(detail.data?.id) === palletId && id(detail.data?.warehouseId) === warehouseId + && id(detail.data?.customerId) === '0' && detail.data?.palletCode === palletCode && detail.data?.status === 'IDLE', + `palletId=${id(detail.data?.id) || 'missing'}, code=${detail.data?.palletCode || 'missing'}, customerId=${id(detail.data?.customerId) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + + const updated = await overseasMutation(context, 'PUT', '编辑本轮公共托盘', detailPath, { + palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'IDLE', + locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`, version: Number(detail.data?.version), + }) + context.assert(MODULE, '公共托盘编辑成功', updated.response, voidMutationSucceeded(updated), + `palletId=${palletId}; ${trace(updated.response)}`, OPTIONS) + const after = await overseasGet(context, '回查公共托盘编辑结果', detailPath) + context.assert(MODULE, '公共托盘编辑字段已生效', after.response, + 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, { + palletCode, palletType: '木托盘', capacityCbm: 9, unitPrice: 9, status: 'IDLE', + remark: 'STALE-DO-NOT-APPLY', version: Number(detail.data?.version), + }) + context.addStep(MODULE, '公共托盘旧版本编辑返回冲突', stale, + stale?.http === 409 || (stale?.http === 200 && String(stale?.body?.code).endsWith('409')), trace(stale), OPTIONS) + } finally { + if (palletId) { + const retired = await overseasMutation(context, 'PUT', '报废本轮公共托盘', + `/api/platform/v1/warehouse-admin/${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), + }) + 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)}`) + 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) + } + } +} + +async function findWarehouse(context, tenants, preferredCustomerId) { + const candidates = [...new Set([preferredCustomerId, ...tenants.map((item) => id(item?.id)).filter(longId)])] + let lastResponse = null + for (const customerId of candidates) { + const result = await overseasGet(context, `查询客户${customerId}可用海外仓`, query('/api/platform/v1/warehouse-admin', { customerId, page: 1, size: 100, status: 'ACTIVE' })) + assertPage(context, `客户${customerId}可用海外仓`, result) + lastResponse = result.response + const warehouse = records(result.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE') + if (warehouse) { + context.assert(MODULE, '海外仓关键字段正确', result.response, + nonBlank(warehouse.warehouseCode) && nonBlank(warehouse.warehouseName), + `warehouseId=${id(warehouse.id)}, code=${warehouse.warehouseCode || 'missing'}; ${trace(result.response)}`, OPTIONS) + const detail = await overseasGet(context, '查询海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(warehouse.id))}`, { customerId })) + context.assert(MODULE, '海外仓详情对应列表记录', detail.response, + id(detail.data?.warehouse?.id || detail.data?.id) === id(warehouse.id), + `listId=${id(warehouse.id)}, detailId=${id(detail.data?.warehouse?.id || detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) + return warehouse + } + } + return null +} diff --git a/scripts/admin-test-env-modules/pricing-contract.test.mjs b/scripts/admin-test-env-modules/pricing-contract.test.mjs new file mode 100644 index 000000000..55cc64b54 --- /dev/null +++ b/scripts/admin-test-env-modules/pricing-contract.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'pricing.mjs'), 'utf8') + +test('price-book acceptance is portable and reuses a deployed active source by default', () => { + assert.match(source, /process\.env\.ADMIN_TEST_PRICE_BOOK_FILE/) + assert.match(source, /activeBefore\[0\]/) + assert.match(source, /requestBinary\('GET', `\$\{PRICE_BOOK_PATH\}\/\$\{encodeURIComponent\(activeFixture\.id\)\}\/source`\)/) + assert.match(source, /复用当前生效价格源作为本轮隔离上传夹具/) + assert.doesNotMatch(source, /WXWork|Users\\BY-OY|DEFAULT_PRICE_BOOK_FILE/) +}) diff --git a/scripts/admin-test-env-modules/pricing.mjs b/scripts/admin-test-env-modules/pricing.mjs index 1853dbf82..91952b572 100644 --- a/scripts/admin-test-env-modules/pricing.mjs +++ b/scripts/admin-test-env-modules/pricing.mjs @@ -5,7 +5,6 @@ import { basename } from 'node:path' const PRICE_BOOK_PATH = '/api/platform/v1/fba-pricing/carrier-rates/price-books' const DOMESTIC_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC' const OVERSEAS_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS' -const DEFAULT_PRICE_BOOK_FILE = 'C:\\Users\\BY-OY\\Documents\\WXWork\\1688854456873012\\Cache\\File\\2026-08\\2026.08.03柏悦价格表 船期2026.08.12.xlsx' const EXPECTED_CHANNEL_CODES = [ 'MATSON_CLX_TRUCK', 'MATSON_MAX_TRUCK', 'EXX_TRUCK', 'HEDE_TRUCK', 'EXX_RAIL_NY', 'OA_LA_TRUCK', 'OA_LA_FULL_TRUCK', 'OA_CHICAGO_RAIL', 'OA_HOUSTON_TRUCK', @@ -134,18 +133,37 @@ export async function run(context) { Array.isArray(versionsBefore) && activeBefore.length <= 1, `versions=${Array.isArray(versionsBefore) ? versionsBefore.length : 'n/a'}, active=${activeBefore.length}`, blocker) - const sourcePath = process.env.ADMIN_TEST_PRICE_BOOK_FILE || DEFAULT_PRICE_BOOK_FILE + const sourcePath = process.env.ADMIN_TEST_PRICE_BOOK_FILE let sourceBytes - try { - sourceBytes = await readFile(sourcePath) - } catch (error) { - context.addStep(module, '读取船期价格测试文件', null, false, - `path=${sourcePath}, error=${error instanceof Error ? error.message : String(error)}`, blocker) - finishScenario(context, module, scenario, start) - return + let sourceName + if (sourcePath) { + try { + sourceBytes = await readFile(sourcePath) + sourceName = basename(sourcePath) + } catch (error) { + context.addStep(module, '读取显式船期价格测试文件', null, false, + `path=${sourcePath}, error=${error instanceof Error ? error.message : String(error)}`, blocker) + finishScenario(context, module, scenario, start) + return + } + } else { + const activeFixture = activeBefore[0] + const sourceResponse = activeFixture?.id + ? await context.requestBinary('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(activeFixture.id)}/source`) + : null + const zipMagic = sourceResponse?.bytes?.[0] === 0x50 && sourceResponse?.bytes?.[1] === 0x4b + const fixtureReady = sourceResponse?.http === 200 && sourceResponse.bytes?.length > 0 && zipMagic + context.addStep(module, '复用当前生效价格源作为本轮隔离上传夹具', sourceResponse, fixtureReady, + `activeVersionId=${activeFixture?.id || 'missing'}, bytes=${sourceResponse?.bytes?.length || 0}, zipMagic=${zipMagic}`, blocker) + if (!fixtureReady) { + finishScenario(context, module, scenario, start) + return + } + sourceBytes = sourceResponse.bytes + sourceName = activeFixture.originalFileName || `price-book-${activeFixture.id}.xlsx` } const sourceHash = sha256(sourceBytes) - const uploadName = `${context.marker}-${basename(sourcePath)}` + const uploadName = `${context.marker}-${sourceName}` context.addStep(module, '船期价格测试文件已隔离', null, sourceBytes.length > 0 && uploadName.includes(context.marker) && uploadName.toLowerCase().endsWith('.xlsx'), `fileName=${uploadName}, bytes=${sourceBytes.length}, sha256=${sourceHash}`, blocker) diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 7d0fb741d..6564558ea 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -30,10 +30,27 @@ async function actionRequest(context, path, endpoint, version) { } } +async function uploadBillOfLading(context, appointmentId) { + const path = `${TRUCK}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING` + const form = new FormData() + const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) + form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-bill.png`) + const response = await fetch(`${context.options.gateway}${path}`, { + method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, + body: form, signal: AbortSignal.timeout(60_000), + }) + const body = await response.json() + return { method: 'POST', path, http: response.status, body, + requestId: body?.requestId || response.headers.get('x-request-id'), + traceId: body?.traceId || response.headers.get('x-trace-id') } +} + export async function run(context) { - const ocean = context.createdResources?.oceanBooking const containerFlow = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading + const ocean = containerFlow?.oceanBookingId + ? { id: id(containerFlow.oceanBookingId), finalStatus: 'RELEASED' } + : context.createdResources?.oceanBooking const containerFixture = [ context.createdResources?.orders, context.createdResources?.orchestrationFixtures?.inboundMeasurement, @@ -70,6 +87,13 @@ export async function run(context) { `containerBookingId=${id(containerFlow.containerBookingId)}, containers=${oceanDetail?.containers?.length ?? 'n/a'}`, OPTIONS) if (!allocationMatches) return } + const carrierResponse = await context.request('GET', + '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20') + const carrierPage = context.apiSuccess(MODULE, '读取 ACTIVE TRUCKING 承运商事实源', carrierResponse, '', OPTIONS) + const carrier = carrierPage?.records?.find?.((item) => item?.id && item?.name) + context.assert(MODULE, '跨服务拖车使用正式 ACTIVE TRUCKING 承运商', carrierResponse, + Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS) + if (!carrier) return const payload = { outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null, oceanBookingId: ocean.id, @@ -78,7 +102,7 @@ export async function run(context) { warehouseId: linkContainer ? id(containerFlow.warehouseId) : id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), - customerId, customerName, carrierId: null, carrierName: null, + customerId, customerName: null, carrierId: id(carrier.id), carrierName: carrier.name, fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008', driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000', @@ -105,6 +129,9 @@ export async function run(context) { `appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) if (!appointmentId) return const path = `${TRUCK}/${appointmentId}` + const billUpload = await uploadBillOfLading(context, appointmentId) + const billAttachment = context.apiSuccess(MODULE, '跨服务拖车上传有效提单附件', billUpload, '', OPTIONS) + if (!billAttachment?.id) return let version = Number(appointment.version) for (const [name, endpoint, status] of [ ['提交跨链路预约', 'submit', 'PENDING_DISPATCH'], diff --git a/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs b/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs new file mode 100644 index 000000000..d80e26117 --- /dev/null +++ b/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./supplemental-boundaries.mjs', import.meta.url), 'utf8') + +test('occupied location deletion protection reuses only the marker-owned loading fixture', () => { + assert.match(source, /phase3Loading/) + assert.match(source, /inventoryMutationCleanup\?\.targetLocationId/) + assert.match(source, /loading\?\.marker === context\.marker/) + assert.match(source, /真实占用库位删除返回A0409且保留资源/) + assert.match(source, /占用删除冲突未造成资源丢失/) + assert.doesNotMatch(source, /no-public-fixture-api|当前公共验收 API 未提供/) +}) diff --git a/scripts/admin-test-env-modules/supplemental-boundaries.mjs b/scripts/admin-test-env-modules/supplemental-boundaries.mjs index 7ca47c5ae..a6057b65d 100644 --- a/scripts/admin-test-env-modules/supplemental-boundaries.mjs +++ b/scripts/admin-test-env-modules/supplemental-boundaries.mjs @@ -86,11 +86,30 @@ export async function run(context) { locationVersion = Number(afterData?.location?.version) } - // Public APIs do not expose a safe way to create a real occupied pallet/ - // location association without an inbound allocation fixture. Keep this as - // an explicit automation gap instead of claiming a deletion-protection pass. - context.addCoverageGap(MODULE, 'resource-occupancy', '托盘/库位真实占用删除保护', - '当前公共验收 API 未提供可安全构造并释放占用关联的独立 fixture;未操作历史资源', 'no-public-fixture-api') + // phase3-loading has already created and rebound a marker-owned live + // occupancy. Reuse that exact location to prove deletion protection without + // touching historical resources or inventing a setup-only API. + const loading = context.createdResources?.phase3Loading + const occupiedLocationId = id(loading?.inventoryMutationCleanup?.targetLocationId) + const occupiedWarehouseId = id(loading?.warehouseId) + context.assert(MODULE, '取得本轮装柜链真实占用库位', null, + Boolean(occupiedLocationId && occupiedWarehouseId && loading?.marker === context.marker), + `locationId=${occupiedLocationId || 'missing'}, warehouseId=${occupiedWarehouseId || 'missing'}`, OPTIONS) + if (occupiedLocationId && occupiedWarehouseId && loading?.marker === context.marker) { + const occupiedPath = `/api/platform/v1/locations/${occupiedLocationId}?tenantId=0&warehouseId=${occupiedWarehouseId}` + const occupiedDetail = await context.request('GET', occupiedPath) + const occupied = context.apiSuccess(MODULE, '读取本轮真实占用库位版本', occupiedDetail, '', OPTIONS) + const occupiedVersion = Number(occupied?.location?.version) + const rejectedDelete = await context.request('DELETE', `${occupiedPath}&version=${occupiedVersion}`) + context.addStep(MODULE, '真实占用库位删除返回A0409且保留资源', rejectedDelete, + rejectedDelete.http === 200 && ['A0409', '409'].includes(codeOf(rejectedDelete)), + `HTTP=${rejectedDelete.http}, code=${codeOf(rejectedDelete)}, version=${occupiedVersion}`, OPTIONS) + const occupiedAfter = await context.request('GET', occupiedPath) + const after = context.apiSuccess(MODULE, '删除冲突后回查占用库位', occupiedAfter, '', OPTIONS) + context.assert(MODULE, '占用删除冲突未造成资源丢失', occupiedAfter, + id(after?.location?.id) === occupiedLocationId, + `expected=${occupiedLocationId}, actual=${id(after?.location?.id) || 'missing'}`, OPTIONS) + } // Import boundary probes use only in-memory bytes and dry-run where supported. const bogusXlsx = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]) diff --git a/scripts/admin-test-env-modules/supplemental.mjs b/scripts/admin-test-env-modules/supplemental.mjs index 24a95aa72..28bef19f7 100644 --- a/scripts/admin-test-env-modules/supplemental.mjs +++ b/scripts/admin-test-env-modules/supplemental.mjs @@ -46,6 +46,10 @@ export async function run(context) { const module = 'supplemental' const scenario = 'service-health-and-auth-boundary' const blocker = { scenario, deploymentBlocker: true } + const customerId = String(context.context.merchantQualificationFixture?.tenantId || '') + context.assert(module, '客户作用域负向检查使用本轮正式客户', null, Boolean(customerId), + `customerId=${customerId || 'missing'}`, blocker) + if (!customerId) return // Health endpoints are part of the deployed service contract. Warehouse is // special: a schema/readiness failure is intentionally HTTP 503, not a fake @@ -102,7 +106,7 @@ export async function run(context) { } for (const [name, path] of [ ['仓库非法状态', '/api/platform/v1/warehouse-admin?page=1&size=5&status=NOT_A_STATUS'], - ['仓库客户作用域', '/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=10001'], + ['仓库客户作用域', `/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], ]) { const response = await context.request('GET', path) context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response), @@ -118,8 +122,8 @@ export async function run(context) { await checkPage(context, module, '托盘仓库筛选', `${palletBase}&warehouseId=${warehouseId}`, { scenario: 'warehouse-resources' }) } const resourceScopeChecks = [ - ['库位客户作用域', '/api/platform/v1/locations?page=1&size=5&tenantId=10001'], - ['托盘客户作用域', '/api/platform/v1/pallets?page=1&size=5&tenantId=10001'], + ['库位客户作用域', `/api/platform/v1/locations?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], + ['托盘客户作用域', `/api/platform/v1/pallets?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], ['库位非法状态', `${locationBase}&status=ALLOCATED`], ] for (const [name, path] of resourceScopeChecks) { diff --git a/scripts/admin-test-env-modules/suppliers.mjs b/scripts/admin-test-env-modules/suppliers.mjs index abb38b91a..ae2b96255 100644 --- a/scripts/admin-test-env-modules/suppliers.mjs +++ b/scripts/admin-test-env-modules/suppliers.mjs @@ -1,8 +1,8 @@ const MODULE = 'suppliers' const OPTIONS = { scenario: 'platform-public-suppliers', deploymentBlocker: true } const BASE = '/api/platform/v1/suppliers' -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function code(response, expected) { return response?.http === 200 && String(response?.body?.code) === expected } diff --git a/scripts/admin-test-env-modules/system.mjs b/scripts/admin-test-env-modules/system.mjs index 7a1c877b6..a76a33c2a 100644 --- a/scripts/admin-test-env-modules/system.mjs +++ b/scripts/admin-test-env-modules/system.mjs @@ -146,6 +146,21 @@ export async function run(context) { const resetLoginResponse = await context.request('POST', '/api/auth/login', { 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, + 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) + context.assert(module, '缺少canonical海外订单权限必须403', denied, + (denied.http === 403 || ['403', 'A0403'].includes(String(denied.body?.code))) + && Boolean(denied.requestId) && Boolean(denied.traceId), + `HTTP=${denied.http}, code=${denied.body?.code ?? 'missing'}, requestId=${denied.requestId || 'missing'}, traceId=${denied.traceId || 'missing'}`, + { scenario: 'canonical-overseas-permission-denial', deploymentBlocker: true }) + } + } const disableUserResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/status`, { status: 'DISABLED' }) const disabledUser = context.apiSuccess(module, '停用平台内部用户', disableUserResponse) diff --git a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md new file mode 100644 index 000000000..92fdfa315 --- /dev/null +++ b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md @@ -0,0 +1,445 @@ +# 批量待分柜数据生成指南 + +本文说明如何复用 `scripts/admin-test-env-acceptance.mjs`,通过正式 HTTP 业务接口批量生成停在“测量完成待分柜”的头程订单。生成链路包含客户登录、下单、平台审核、入库、称重、测量、测量审核,不直接写业务数据库,也不模拟成功。 + +## 适用范围 + +- 用于开发、联调或经明确授权的验收环境准备人工操作数据。 +- 生成结果固定为入库单 `processStatus=WAIT_SPLIT`、测量审核 `APPROVED`。 +- 单次支持 `1-100` 条;更大批量分多次执行并分别保存报告。 +- 基础批量模式不自动执行后续分柜、装柜、订舱、国内出仓、海外入仓或 PDA 收货;需要人工分柜验收时使用本文后半部分的准备与只读核验入口。 +- 禁止用于生产环境。对任何共享环境执行前,必须在当前对话明确获得该环境的写数据授权。 + +## 固定项与随机项 + +批量模式可固定以下三个线路字段: + +1. 国内起运仓名称:`--origin-warehouse-name` +2. 渠道名称:`--channel-name` +3. 渠道编码:`--channel-code` + +客户通过复用已审核商户账号固定;服务端从登录身份解析客户,脚本不会手填客户 ID。 + +使用 `--order-profile-mode randomized` 后,脚本在服务端允许的真实组合中轮换: + +- 目的类型:`FBA`、`OVERSEAS`、`PRIVATE` +- 履约类型:`STANDARD`、`ONE_CLICK_DROPSHIP` +- 提货方式:`PICKUP`、`SELF_EXPRESS`、`SELF_TRUCK` +- 买单/退税:`BUY_DECLARATION`、`TAX_REFUND` +- 报价税制:`TAX_INCLUDED`、`TAX_EXCLUDED`,以固定渠道实际支持项为准 +- 装载方式:`LCL`、`FULL_CONTAINER` +- 是否查验 +- SKU 数、每 SKU 箱数、托数、重量和测量箱型 +- FBA/海外仓目的仓:只从固定渠道当前价格簿真实可计价仓库中选择 + +随机档案是可重复的稳定轮换,不是不可复现的纯随机数。每六条覆盖一次“3 种目的类型 × 2 种履约类型”的核心组合,便于复核覆盖率。 + +特殊规则: + +- `PRIVATE` 不引用公共目的仓 ID,必须携带收件人地址;当前契约不对私人地址做自动报价。 +- `FBA` 必须使用真实可计价 FBA 目的仓,并冻结 Amazon 仓码、Shipment ID 和 Reference ID。 +- `OVERSEAS` 必须使用真实可计价海外仓;固定渠道只有一个可用海外仓时允许重复使用,不伪造第二个仓。 +- 默认 `--order-profile-mode fixed` 仍生成 FBA + STANDARD,不启用上述随机组合。 + +## 执行前置 + +执行前确认: + +- 目标 Gateway 和服务已部署当前接口。 +- 平台管理员账号可用。 +- 复用客户已审核通过,商户账号可登录。 +- 客户固定箱型初始化完成。 +- 固定国内仓为 `ACTIVE` 且具备收货能力。 +- 固定渠道存在当前 `ACTIVE` 价格簿和进仓码前缀。 +- FBA/OVERSEAS 至少存在一个与固定渠道价格明细匹配的目的仓。 +- File、Order、Firstleg、Warehouse、IAM、Auth 等链路可用。 + +账号、密码、环境域名和 Token 不得写入本文、源码、提交记录或报告。建议由本地未提交环境文件或当前终端环境变量提供。 + +## PowerShell 命令模板 + +先在当前终端提供敏感执行参数: + +```powershell +$env:ADMIN_TEST_GATEWAY = '<目标环境国内 Gateway>' +$env:ADMIN_TEST_USERNAME = '<平台管理员账号>' +$env:ADMIN_TEST_PASSWORD = '<平台管理员密码>' +$env:MERCHANT_TEST_USERNAME = '<已审核商户账号>' +$env:MERCHANT_TEST_PASSWORD = '<已审核商户密码>' +``` + +从 `baiyue-firstleg/` 执行: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules inbound-measurement ` + --merchant-username $env:MERCHANT_TEST_USERNAME ` + --merchant-password $env:MERCHANT_TEST_PASSWORD ` + --origin-warehouse-name '<固定国内仓名称>' ` + --channel-name '<固定渠道名称>' ` + --channel-code '<固定渠道编码>' ` + --order-profile-mode randomized ` + --wait-split-count 100 ` + --output '<本地报告绝对路径>.json' +``` + +`inbound-measurement` 会自动展开 `orders` 前置。提供完整商户账号和密码后,总入口会复用该客户并移除自动创建客户的 `merchant-qualification-fixture`;两个商户参数必须同时提供。 + +如果需要自动新建并审核一个隔离客户,不传复用商户账号,改用: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules merchant-qualification-fixture,orders,inbound-measurement ` + --customer-name '<本轮客户名称>' ` + --origin-warehouse-name '<固定国内仓名称>' ` + --channel-name '<固定渠道名称>' ` + --channel-code '<固定渠道编码>' ` + --order-profile-mode randomized ` + --wait-split-count 100 ` + --output '<本地报告绝对路径>.json' +``` + +## 报告核验 + +成功报告中的 `createdResources.waitSplitFixtures` 每条至少应核对: + +- `tenantId`、`orderId`、`inboundId`、`inboundCode` +- `processStatus=WAIT_SPLIT` +- `reviewStatus=APPROVED` +- `receivingWarehouseName`、`channelCode`、`channelName` +- `destinationType`、目的仓字段、`fulfillmentType` +- `pickupMethod`、`taxMode`、`pricingTaxMode`、`containerMode`、`customsInspection` +- SKU 数、总箱数、托数、毛重、净重和测量档案 + +建议同时检查: + +- fixture 数量等于本轮 `--wait-split-count` +- `inboundCode` 去重数等于 fixture 数量 +- 固定客户、国内仓和渠道只有一个唯一值 +- `failed=0` 且报告顶层 `success=true` +- 随机批次包含预期目的类型和履约类型 + +报告生成前会扫描密码、Token、文件二进制、base64 和 OCR 原文等敏感内容。报告仍属于本地验收证据,不应提交到源码仓库。 + +## 人工分柜验收 + +### 1. 生成分柜准备清单 + +人工分柜模块默认生成 6 票同客户、同起运仓、同渠道的 `WAIT_SPLIT` 数据,并在报告的 `createdResources.manualOrderSplittingFixture` 中给出: + +- 一票 `PARTIAL`:只分配部分箱数,固定保留 1 箱; +- 两票 `FULL_1/FULL_2`:整票加入同一个柜,验证多订单拼柜; +- 一票 `UNASSIGNED_CONTROL`:不得加入计划,用于核对未分配对照和候选余量; +- 全部订单、入库单、入仓码、箱数、客户、仓库和渠道信息。 + +复用既有客户时执行: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-order-splitting-fixture ` + --merchant-username $env:MERCHANT_TEST_USERNAME ` + --merchant-password $env:MERCHANT_TEST_PASSWORD ` + --origin-warehouse-name '<固定国内仓名称>' ` + --channel-name '<固定渠道名称>' ` + --channel-code '<固定渠道编码>' ` + --order-profile-mode fixed ` + --wait-split-count 6 ` + --output '<人工分柜准备报告绝对路径>.json' +``` + +`manual-order-splitting-fixture` 会自动展开 `orders → inbound-measurement`。本入口通过正式 HTTP 接口写入测试数据,仍需当前对话明确授权目标环境。 + +### 2. Admin 人工操作 + +根据准备报告执行: + +1. 打开智能装柜页面,创建与清单一致的起运仓、渠道模拟柜。 +2. 创建智能装柜计划。 +3. 严格按 `assignments[].expectedAllocatedPackageCount` 分配三票入仓码。 +4. 不要选择 `unassignedControl`。 +5. 保存计划但暂不推送预装,记录页面或接口返回的 `loadingPlanId`。 + +此阶段只是“计划分柜”。父子订单和最终实装数量要到 PDA 实装结算后形成,不能在本节点提前宣称已经完成订单拆分。 + +### 3. 人工操作后只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-order-splitting-verify ` + --manual-split-fixture-report '<人工分柜准备报告绝对路径>.json' ` + --manual-split-plan-id '<页面记录的 loadingPlanId>' ` + --output '<人工分柜核验报告绝对路径>.json' +``` + +核验模块只发送 GET 请求,检查: + +- 计划与柜次 ID 有效,计划仍处于可继续验收的 `EDITING/LOADING`; +- 柜次仓库、渠道与准备清单一致; +- `PARTIAL/FULL_1/FULL_2` 的计划箱数精确匹配; +- 未混入清单外入库单,`UNASSIGNED_CONTROL` 未被误选; +- 候选回查不出现异渠道或异仓入库单; +- 每票分配后的候选剩余箱数满足数量守恒。 + +核验报告会写入 `createdResources.manualOrderSplittingVerification`,记录来源 marker、计划、柜次和已核验分配,不保存凭据。 + +## 人工推送预装验收 + +完成上一节人工分柜核验后,继续验证“点击推送预装,但尚无真实柜号、未关联订舱”的节点。 + +### 1. 生成推送前操作清单 + +使用上一节生成的“人工分柜核验报告”: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-preload-push-fixture ` + --manual-preload-source-report '<人工分柜核验报告绝对路径>.json' ` + --output '<推送预装前准备报告绝对路径>.json' +``` + +该模块只发送 GET 请求,并确认: + +- 智能装柜计划仍为 `EDITING`; +- 人工分柜明细存在且版本有效; +- 尚未建立预装 bridge; +- 柜次仍为无真实 `containerNo`、无 `oceanBookingId` 的内部模拟柜。 + +成功后,报告中的 `createdResources.manualPreloadPushFixture` 会保存推送前计划版本、明细快照和操作说明。 + +### 2. Admin 人工推送 + +在同一智能装柜计划上点击“推送预装”。本节点只做这一项操作: + +- 不录入真实集装箱号; +- 不创建或关联订舱; +- 不确认预装计划; +- 不领取或创建 PDA 任务。 + +推送成功后立即执行下一步只读核验。 + +### 3. 推送后只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-preload-push-verify ` + --manual-preload-source-report '<推送预装前准备报告绝对路径>.json' ` + --output '<推送预装核验报告绝对路径>.json' +``` + +核验内容包括: + +- 计划仍为 `EDITING`,版本相对推送前递增,分柜明细没有漂移; +- 每票入库单生成 `PENDING` 预装 bridge,箱数与人工分柜一致; +- 正式预装清单详情可见; +- 无真实柜号阶段 `canConfirm=false`、`readyForPda=false`; +- PDA 任务查询返回 `A0404`; +- 推送没有伪造真实柜号、订舱关联或改变稳定柜次身份。 + +通过后报告写入 `createdResources.manualPreloadPushVerification`,下一节点再处理订舱放舱、真实柜号关联和整份预装计划确认。 + +## 人工订舱关联、预装确认与 PDA 待领取验收 + +### 1. 冻结操作前基线 + +以上一节“推送预装核验报告”为来源: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-ready-fixture ` + --manual-pda-source-report '<推送预装核验报告绝对路径>.json' ` + --output '.json' +``` + +准备模块只发送 GET 请求,确认: + +- 已推送计划仍为 `EDITING`,版本和分柜明细没有变化; +- 原柜次仍无真实柜号、无订舱关联; +- 当前 `canConfirm=false`、`readyForPda=false`; +- PDA 查询仍返回 `A0404`。 + +报告的 `createdResources.manualPdaReadyFixture` 保存柜次、计划、版本和明细基线。 + +### 2. Admin 人工操作 + +严格按以下顺序操作: + +1. 创建与准备清单客户、起运仓、渠道、柜型一致的海运订舱 DRAFT,并补齐承运船司代码 `carrierCode`、提单号、船名、航次、ETD、ETA、参与方和柜型需求。 +2. 点击放舱,使订舱进入 `RELEASED`。此时不应自动生成真实柜次或 PDA 任务。 +3. 在原内部柜次上选择该订舱未占用的柜型需求,录入唯一真实 `containerNo` 完成关联;不得新建替代柜次。 +4. 回到预装详情,确认门禁变为 `canConfirm=true`,再执行整份预装计划确认。 +5. 确认成功后不要领取 PDA 任务,记录订舱号和真实柜号并立即执行核验。 + +### 3. 只读核验 PDA 待领取状态 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-ready-verify ` + --manual-pda-source-report '.json' ` + --output '.json' +``` + +核验内容包括: + +- 订舱为 `RELEASED`,且 allocation 唯一指向原 `containerBookingId`; +- 真实柜号在原柜次上补全,`cabinetNo/loadingPlanId/items` 不漂移; +- 整份预装为 `CONFIRMED`、`readyForPda=true`,并返回唯一 `pdaTaskId`; +- PDA task 四元身份正确,`taskStatus=LOADING`、`workflowStatus=UNASSIGNED`; +- PDA 计划箱数与人工分柜总箱数一致; +- 尚未领取任务时各来源入库单仍保持 `WAIT_SPLIT`。 + +通过后报告写入 `createdResources.manualPdaReadyVerification`。下一节点进入 PDA 人工领取、扫码、批量提交和待复核验收。 + +## PDA 人工领取、扫码和批量提交验收 + +### 1. 冻结 PDA 作业前基线 + +以上一节“PDA 待领取核验报告”为来源: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-batch-fixture ` + --manual-pda-batch-source-report '.json' ` + --output '.json' +``` + +准备模块只发送 GET 请求,确认并记录: + +- 任务为 `UNASSIGNED`,尚未绑定作业员; +- PDA 待办中只有同一稳定 `taskId`; +- 每条任务明细包含稳定 `boxCodes`,数量足以覆盖计划箱数; +- 库存快照的任务版本和四元身份一致; +- 操作历史尚无 `CLAIM/BATCH/REVIEW/COMPLETE_LOADING`。 + +报告的 `createdResources.manualPdaBatchFixture.taskItems[].scanBoxCodes` 提供本轮应扫描箱码,`boxCodes` 保留服务端完整下发集合;库存 `snapshotToken` 不写入报告,PDA 提交前必须实时重新获取。 + +### 2. PDA 真机人工操作 + +1. 使用目标仓库作业员进入 PDA 待办并领取任务,确认页面进入作业中。 +2. 按准备报告对应任务明细,在真机逐箱扫描稳定箱码;同一箱码不能重复扫描。 +3. 所有明细均按计划箱数完成,本节点不使用 `PARTIAL` 或 `SKIP`。 +4. 点击提交时由 PDA 重新获取库存快照,并只发送一次原子 batch。 +5. 成功后停在 `AWAITING_REVIEW`,不要上传五类证据,不要复核或执行最终完成。 + +服务端 batch 只接收明细与数量,不保存逐次扫码轨迹。因此自动报告只能证明稳定箱码已下发和服务端数量已收敛,真机逐箱扫码过程必须保留人工证据。 + +### 3. 批量提交后只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-batch-verify ` + --manual-pda-batch-source-report '.json' ` + --output '.json' +``` + +核验内容包括: + +- 任务已绑定真实作业员,版本递增; +- 所有明细均为 `PICKED`,已拣箱数等于计划箱数; +- 任务保持 `taskStatus=LOADING`,工作流进入 `AWAITING_REVIEW`; +- pending 与 detail 均显示零条未处理明细; +- 操作历史恰有一次 `CLAIM`、一次 `BATCH`,没有 `REVIEW/COMPLETE_LOADING`; +- 各入库单服务端汇总 `expectedCount=pickedCount`; +- 待复核取证 GET 已开放。 + +通过后报告写入 `createdResources.manualPdaBatchVerification`,并明确标记逐次扫码仍需真机证据。下一节点处理五类装柜证据和 PDA 最终完成。 + +## 五类装柜证据、PDA 最终完成与 LOADED 验收 + +### 1. 冻结最终完成前基线 + +以上一节“PDA 待复核核验报告”为来源: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-loading-complete-fixture ` + --manual-loading-complete-source-report '.json' ` + --output '<装柜最终完成前准备报告绝对路径>.json' +``` + +准备模块只发送 GET 请求,确认: + +- PDA 任务为 `AWAITING_REVIEW`,所有明细已拣齐; +- 装柜计划仍为 `LOADING/IN_PROGRESS`; +- 任务审计只有一次 `CLAIM` 和一次 `BATCH`; +- 当前证据归属入库单没有 ACTIVE 装柜证据; +- 真实柜号和 `RELEASED` 订舱门禁仍有效。 + +报告的 `createdResources.manualLoadingCompleteFixture` 会固定证据归属、五类证据要求和最终完成前版本。 + +### 2. PDA 真机人工操作 + +在同一 PDA 任务内现场完成: + +1. 拍摄空仓照 `PHOTO_STEP_1`。 +2. 拍摄半仓照 `PHOTO_STEP_2`。 +3. 拍摄满仓照 `PHOTO_STEP_3`。 +4. 拍摄锁仓照 `PHOTO_STEP_4`。 +5. 拍摄带锁码照 `SEAL`,并填写非空锁码。 +6. 确认五类均为 ACTIVE 后,只点击一次最终完成。 + +不要调用旧入库单级兼容完成入口;当前唯一正式动作是 PDA task 的最终完成。成功后不要继续修改柜号、计划或证据。 + +### 3. LOADED 终态只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-loading-complete-verify ` + --manual-loading-complete-source-report '<装柜最终完成前准备报告绝对路径>.json' ` + --output '.json' +``` + +核验内容包括: + +- 五类 ACTIVE 证据各一份,SEAL 锁码非空且每张图片可鉴权下载; +- 任务 `taskStatus/workflowStatus` 均为 `COMPLETED`; +- 操作历史恰有一次 `COMPLETE_LOADING`,并保留原 CLAIM/BATCH; +- 原装柜计划进入 `COMPLETED`; +- 所有来源入库单进入 `LOADED`,状态历史包含 `LOADING → LOADED`; +- 对应统一订单详情投影为 `LOADED`; +- 柜次实装分片唯一,实装箱数等于 PDA 已拣总箱数; +- 货柜总览聚合 COMPLETED PDA 和五类 ACTIVE 证据。 + +通过后报告写入 `createdResources.manualLoadingCompleteVerification`。至此,本指南覆盖的国内人工验收链已经从订单造数连续到装柜 `LOADED`;后续报关、国内出仓和海外接柜另按对应节点扩展。 + +## 失败、重跑与批量拆分 + +- 批次遇到第一条失败即停止,已经成功创建的订单不会自动删除或回滚。 +- 先查看日志和 JSON 报告中最后一个成功 fixture、失败接口的 `code/requestId/traceId`,修复前置后再运行剩余数量。 +- 重跑会生成新业务单号和入仓码,不覆盖旧订单;不要假设跨多次运行的入仓码一定连续。 +- 每次运行的随机序列从 1 重新开始;分批执行时组合可能重复,但仍保持固定三项和真实契约。 +- 大于 100 条时建议按 50-100 条拆批,并为每批使用独立报告文件名。 +- 若固定渠道只有少量目的仓,目的仓重复是合法结果;脚本只保证从真实可计价集合选择,不保证每批目的仓均匀分布。 + +## 常见阻断 + +| 现象 | 核对项 | +|---|---| +| 未找到固定起运仓 | 仓库名称是否精确一致、状态是否 ACTIVE、类型是否 DOMESTIC | +| 未找到固定物流渠道 | 渠道编码/名称是否精确一致、价格簿是否 ACTIVE、起运仓是否匹配 | +| 没有可计价目的仓 | 价格簿目的类型、目的仓映射、报价税制和仓库状态 | +| 私人地址创建失败 | `destinationWarehouseId` 必须为空,收件人字段必须完整 | +| 测量审核未到 WAIT_SPLIT | 称重图片、托数、净重、总箱数和测量箱数是否精确核平 | +| 报告数量不足 | 查看首个失败模块;已成功数据不会因后续失败回滚 | + +## 离线验证 + +修改生成器或本文关联参数后,至少运行: + +```powershell +node --check scripts/admin-test-env-acceptance.mjs +node --check scripts/admin-test-env-modules/orders.mjs +node --check scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs +node --check scripts/admin-test-env-modules/manual-order-splitting-verify.mjs +node --check scripts/admin-test-env-modules/manual-preload-push-fixture.mjs +node --check scripts/admin-test-env-modules/manual-preload-push-verify.mjs +node --check scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs +node --check scripts/admin-test-env-modules/manual-pda-ready-verify.mjs +node --check scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs +node --check scripts/admin-test-env-modules/manual-pda-batch-verify.mjs +node --check scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs +node --check scripts/admin-test-env-modules/manual-loading-complete-verify.mjs +node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs +git diff --check +``` + +离线检查不访问环境,也不代表 HTTP 批量生成已经通过。真实生成必须另行获得目标环境写数据授权。 diff --git a/scripts/admin-test-env-report-summary-contract.test.mjs b/scripts/admin-test-env-report-summary-contract.test.mjs new file mode 100644 index 000000000..1171aecce --- /dev/null +++ b/scripts/admin-test-env-report-summary-contract.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' + +test('report summary separates assertion, module-run and unique-request metrics', () => { + const sharedResponse = { + method: 'GET', path: '/api/platform/v1/orders?page=1', requestId: 'request-1', + } + const steps = [ + { module: 'orders', name: '订单列表成功', passed: true, + request: { method: sharedResponse.method, path: sharedResponse.path }, requestId: sharedResponse.requestId }, + { module: 'orders', name: '订单列表数据结构正确', passed: true, + request: { method: sharedResponse.method, path: sharedResponse.path }, requestId: sharedResponse.requestId }, + { module: 'orders', name: '订单列表成功', passed: false, + request: { method: 'GET', path: '/api/platform/v1/orders?page=2' }, requestId: 'request-2' }, + { module: 'report-security', name: '报告敏感信息扫描', passed: true, request: null, requestId: null }, + ] + const modules = [ + { name: 'orders' }, + { name: 'orders' }, + { name: 'finance', skipped: 'owned by finance full chain' }, + ] + + const summary = buildAcceptanceSummary(steps, modules, []) + + assert.deepEqual(summary, { + total: 4, + passed: 3, + failed: 1, + moduleRuns: 2, + uniqueModules: 1, + uniqueRequests: 2, + uniqueStepNames: 3, + skippedModules: 1, + coverageGaps: { total: 0, blocking: 0, nonBlocking: 0, byCategory: {} }, + }) +}) + +test('request evidence without requestId is deduplicated by method and path', () => { + const summary = buildAcceptanceSummary([ + { name: 'CSV 状态', passed: true, request: { method: 'GET', path: '/api/export' }, requestId: null }, + { name: 'CSV 表头', passed: true, request: { method: 'GET', path: '/api/export' }, requestId: null }, + { name: 'CSV POST', passed: true, request: { method: 'POST', path: '/api/export' }, requestId: null }, + ]) + + assert.equal(summary.uniqueRequests, 2) +}) + +test('coverage gaps are counted by category and blocking status without copying details', () => { + const summary = buildAcceptanceSummary([], [], [ + { category: 'device', blocking: false, reason: 'camera', secretEvidence: 'must-not-copy' }, + { category: 'device', blocking: true, reason: 'scanner' }, + { category: 'external-third-party', blocking: false, reason: 'captcha' }, + { blocking: true, reason: 'fixture' }, + ]) + + assert.deepEqual(summary.coverageGaps, { + total: 4, + blocking: 2, + nonBlocking: 2, + byCategory: { + device: { total: 2, blocking: 1, nonBlocking: 1 }, + 'external-third-party': { total: 1, blocking: 0, nonBlocking: 1 }, + uncategorized: { total: 1, blocking: 1, nonBlocking: 0 }, + }, + }) + assert.equal(JSON.stringify(summary).includes('must-not-copy'), false) +}) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 new file mode 100644 index 000000000..eeb4bcdaa --- /dev/null +++ b/scripts/agents-index-contract.ps1 @@ -0,0 +1,561 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$backendRoot = Split-Path -Parent $PSScriptRoot +$workspaceRoot = Split-Path -Parent $backendRoot +$failures = [System.Collections.Generic.List[string]]::new() +$checked = 0 + +function Add-Failure([string]$message) { + $script:failures.Add($message) +} + +function Read-RequiredText([string]$path) { + $script:checked++ + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + Add-Failure "缺少索引文件: $path" + return '' + } + return Get-Content -LiteralPath $path -Raw +} + +function Assert-Contains([string]$text, [string]$needle, [string]$scope) { + $script:checked++ + if (-not $text.Contains($needle)) { + Add-Failure "$scope 未索引: $needle" + } +} + +function Assert-NotContains([string]$text, [string]$needle, [string]$scope) { + $script:checked++ + if ($text.Contains($needle)) { + Add-Failure "$scope 仍残留: $needle" + } +} + +function Assert-Matches([string]$text, [string]$pattern, [string]$scope) { + $script:checked++ + if ($text -notmatch $pattern) { + Add-Failure "$scope 未匹配合同: $pattern" + } +} + +function Assert-Path([string]$relativePath) { + $script:checked++ + $absolutePath = Join-Path $workspaceRoot $relativePath + if (-not (Test-Path -LiteralPath $absolutePath)) { + Add-Failure "索引入口不存在: $relativePath" + } +} + +function Assert-PathMissing([string]$relativePath) { + $script:checked++ + $absolutePath = Join-Path $workspaceRoot $relativePath + if (Test-Path -LiteralPath $absolutePath) { + Add-Failure "已移除入口仍然存在: $relativePath" + } +} + +function Assert-MarkdownTableUnique([string]$path, [string]$heading, [int]$keyColumn, [string]$scope) { + $script:checked++ + $lines = Get-Content -LiteralPath $path + $inside = $false + $keys = [System.Collections.Generic.List[string]]::new() + foreach ($line in $lines) { + if ($line -eq $heading) { + $inside = $true + continue + } + if ($inside -and $line.StartsWith('## ')) { break } + if (-not $inside -or -not $line.StartsWith('|')) { continue } + $cells = @($line.Trim('|').Split('|') | ForEach-Object { $_.Trim() }) + if ($keyColumn -ge $cells.Count -or $cells[$keyColumn] -match '^:?-{3,}:?$') { continue } + $keys.Add($cells[$keyColumn]) + } + $keys | Group-Object | Where-Object Count -gt 1 | ForEach-Object { + Add-Failure "$scope 存在重复索引键: $($_.Name)($($_.Count) 次)" + } +} + +$rootAgentsPath = Join-Path $workspaceRoot 'AGENTS.md' +$adminAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/AGENTS.md' +$clientAgentsPath = Join-Path $workspaceRoot 'apps/client-web/AGENTS.md' +$pdaAgentsPath = Join-Path $workspaceRoot 'apps/pda-uniapp/AGENTS.md' +$overseasPdaAgentsPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/AGENTS.md' +$backendAgentsPath = Join-Path $backendRoot 'AGENTS.md' + +$rootAgents = Read-RequiredText $rootAgentsPath +$adminAgents = Read-RequiredText $adminAgentsPath +$clientAgents = Read-RequiredText $clientAgentsPath +$pdaAgents = Read-RequiredText $pdaAgentsPath +$overseasPdaAgents = Read-RequiredText $overseasPdaAgentsPath +$backendAgents = Read-RequiredText $backendAgentsPath +$engineeringConstraints = Read-RequiredText (Join-Path $workspaceRoot 'docs/AI工程执行与协作约束.md') + +# 当前未上线阶段的无兼容原则必须覆盖全部实现与运行状态,不能再次缩窄为文件或数据库专项规则。 +Assert-Contains $rootAgents '源码结构、接口与 DTO、字段、状态机、权限、配置键、数据库结构与初始化 SQL、事件、路由、页面、Mock/Fixture、测试和脚本' '根无兼容收敛范围' +Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围' +Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则' +Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界' +Assert-Contains $clientAgents '不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API' 'Client 头程无兼容边界' + +# 部署单元必须能从根索引进入。 +Get-ChildItem -LiteralPath (Join-Path $workspaceRoot 'apps') -Directory | + Sort-Object Name | + ForEach-Object { Assert-Contains $rootAgents "apps/$($_.Name)/" '根 AGENTS.md' } +Assert-Contains $rootAgents 'baiyue-firstleg/' '根 AGENTS.md' +Assert-MarkdownTableUnique $rootAgentsPath '## 项目快速入口' 0 '根项目表' +Assert-MarkdownTableUnique $rootAgentsPath '## 项目快速入口' 1 '根项目目录' +Assert-MarkdownTableUnique $rootAgentsPath '## 业务快速路由表' 0 '根业务路由表' +Assert-MarkdownTableUnique $rootAgentsPath '## 原型入口索引' 0 '根原型表' + +# 前端当前全部业务模块必须能从应用级索引命中。 +$applications = @( + @{ Root = 'apps/admin-web/src/modules'; Text = $adminAgents; Scope = 'Admin AGENTS.md' }, + @{ Root = 'apps/client-web/src/modules'; Text = $clientAgents; Scope = 'Client AGENTS.md' } +) +foreach ($application in $applications) { + foreach ($moduleDirectory in (Get-ChildItem -LiteralPath (Join-Path $workspaceRoot $application.Root) -Directory | Sort-Object Name)) { + Assert-Contains $application.Text "modules/$($moduleDirectory.Name)/" $application.Scope + } +} + +# 国内/海外 PDA 只检查 pages.json 正式注册页面,不把空目录或未注册原型纳入索引。 +$pdaApplications = @( + @{ Pages = 'apps/pda-uniapp/pages.json'; Text = $pdaAgents; Scope = 'PDA AGENTS.md'; Prefix = '' }, + @{ Pages = 'apps/pda-overseas-uniapp/src/pages.json'; Text = $overseasPdaAgents; Scope = 'Overseas PDA AGENTS.md'; Prefix = 'src/' } +) +foreach ($pdaApplication in $pdaApplications) { + $pagesJsonPath = Join-Path $workspaceRoot $pdaApplication.Pages + $pagesJson = Get-Content -LiteralPath $pagesJsonPath -Raw | ConvertFrom-Json + $registeredPageRoots = $pagesJson.pages.path | + ForEach-Object { $parts = $_ -split '/'; "$($pdaApplication.Prefix)$($parts[0])/$($parts[1])/" } | + Sort-Object -Unique + foreach ($pageRoot in $registeredPageRoots) { + Assert-Contains $pdaApplication.Text $pageRoot $pdaApplication.Scope + } +} + +# 海外 PDA 当前只开放真实拆柜收货;其余后端虽已存在,前端接入前必须保持禁用。 +$overseasWorkbenchPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js' +$overseasWorkbench = Read-RequiredText $overseasWorkbenchPath +Assert-Matches $overseasWorkbench "(?s)name:\s*'拆柜收货'.{0,300}?available:\s*true" 'Overseas PDA 拆柜收货开关' +foreach ($disabledOperation in @( + '库存盘点', '商品移库', '托盘管理', + '换标收货', '换标执行', '换标复核', + '库存查询', '库位查询', '托盘查询' +)) { + $disabledPattern = "(?s)name:\s*'$([regex]::Escape($disabledOperation))'.{0,300}?available:\s*false" + Assert-Matches $overseasWorkbench $disabledPattern "Overseas PDA 禁用入口 $disabledOperation" +} + +# 文档声明为正式开放的 Client 海外仓页面必须仍是 active 路由。 +$clientOverseasRoutesPath = Join-Path $workspaceRoot 'apps/client-web/src/modules/overseas-warehouse/routes.ts' +$clientOverseasRoutes = Read-RequiredText $clientOverseasRoutesPath +foreach ($route in @( + 'overseas-warehouse/labeling', + 'overseas-warehouse/storage-transfer', + 'overseas-warehouse/cargo-disposal', + 'overseas-warehouse/return-relabel' +)) { + $routePattern = "(?s)path:\s*'$([regex]::Escape($route))'.{0,500}?release:\s*'active'" + 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/merchant/v1/warehouses/**', + '/api/platform/v1/warehouse-admin/**' +)) { + Assert-Contains $overseasGatewayConfig $gatewayPath 'Overseas 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 消费均指向同一海外服务。 +$serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml') +$overseasCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.overseas.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 $adminAgents '海外出库、派送、异常与回访(范围外现存代码)' 'Admin 四期范围边界' +Assert-Contains $pdaAgents 'VITE_H5_API_BASE_URL' '国内 PDA H5 运行配置' +Assert-Contains $overseasPdaAgents 'tests/app-icons.test.mjs' '海外 PDA 图标合同' +Assert-Contains $backendAgents 'RestClientTraceInterceptor.java' '后端同步 Trace 入口' +$oceanBookingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml') +Assert-Contains $oceanBookingOpenApi 'never represents linked or ACTIVE containers' '订舱柜量语义' +Assert-Contains $oceanBookingOpenApi '/api/platform/v1/ocean-bookings/link-candidates:' '订舱关联候选契约' +Assert-Contains $oceanBookingOpenApi 'exact warehouseId, channelCode and containerType' '订舱关联候选筛选语义' + +$adminOceanBookingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts') +$adminOceanBookingView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue') +$adminOceanBookingViewTest = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts') +Assert-Contains $adminOceanBookingApi '/link-candidates' 'Admin 订舱关联候选调用' +Assert-Contains $adminOceanBookingView "label: '订舱柜量'" 'Admin 订舱柜量文案' +Assert-Contains $adminOceanBookingViewTest 'without detail N+1' 'Admin 订舱列表无详情扇出合同' + +# 已注册但超出当前四期边界的海外出库及派生运营页只能作为范围漂移事实存在,不得进入根正式业务路由。 +$adminOverseasRoutes = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/routes.ts') +foreach ($outOfScopeOverseasRoute in @( + 'overseas-warehouse/outbounds', + 'overseas-warehouse/delivery-orders', + 'overseas-warehouse/exception-orders', + 'overseas-warehouse/customer-visits' +)) { + Assert-Contains $adminOverseasRoutes "path: '$outOfScopeOverseasRoute'" "Admin 范围外现存路由 $outOfScopeOverseasRoute" +} +Assert-Contains $adminAgents '海外出库、派送、异常与回访(范围外现存代码)' 'Admin 海外范围漂移警示' +foreach ($outOfScopeRootEntry in @( + 'OutboundsView.vue', 'DeliveryOrdersView.vue', 'ExceptionOrdersView.vue', 'CustomerVisitsView.vue' +)) { + Assert-NotContains $rootAgents $outOfScopeRootEntry '根正式四期业务路由' +} + +# Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。 +$navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts') +Assert-Contains $navigationRecovery 'const RECOVERY_WINDOW_MS = 30_000' 'Admin 路由恢复窗口' +Assert-Contains $navigationRecovery 'if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true' 'Admin 未保存编辑保护' +Assert-Contains $navigationRecovery 'if (!isStaleRouteAssetError(error))' 'Admin 普通路由错误边界' + +# 国内 PDA 的 H5 地址必须来自当前构建和同源代理,不能被历史登录缓存覆盖。 +$domesticPdaRuntime = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/config/runtime.js') +$domesticPdaVite = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/vite.config.js') +$domesticPdaSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/stores/session.js') +Assert-Contains $domesticPdaRuntime "VITE_H5_API_BASE_URL || '/api'" '国内 PDA H5 同源 API' +Assert-Contains $domesticPdaVite 'API_PROXY_TARGET' '国内 PDA H5 代理上游' +Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API' +Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA 会话持久化' + +# 国内和海外 PDA 使用相同协议但完全独立的 APK/WGT 更新通道。 +$domesticPdaUpdater = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/utils/app-update.js') +$domesticPdaApp = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/App.vue') +$overseasPdaUpdater = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/utils/app-update.js') +$overseasPdaApp = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/App.vue') +foreach ($pdaUpdate in @( + @{ Source = $domesticPdaUpdater; Scope = '国内 PDA 在线更新' }, + @{ Source = $overseasPdaUpdater; Scope = '海外 PDA 在线更新' } +)) { + Assert-Contains $pdaUpdate.Source 'VITE_PDA_UPDATE_URL' $pdaUpdate.Scope + Assert-Contains $pdaUpdate.Source "['apk', 'wgt'].includes(packageType)" $pdaUpdate.Scope + Assert-Contains $pdaUpdate.Source '更新包必须使用 HTTPS 地址' $pdaUpdate.Scope + Assert-Contains $pdaUpdate.Source 'plus.runtime.install' $pdaUpdate.Scope +} +Assert-Contains $domesticPdaApp '#ifdef APP-PLUS' '国内 PDA App 更新平台边界' +Assert-Contains $overseasPdaApp '#ifdef APP-PLUS' '海外 PDA App 更新平台边界' +Assert-Contains $pdaAgents '不得复用海外 PDA' '国内 PDA 独立更新通道' +Assert-Contains $overseasPdaAgents '不得与国内 PDA 混用' '海外 PDA 独立更新通道' + +# 人工分柜到 LOADED 只允许使用已登记的可选 fixture/verify 链,不进入默认全量。 +$adminAcceptanceEntry = Read-RequiredText (Join-Path $backendRoot 'scripts/admin-test-env-acceptance.mjs') +$adminAcceptanceModuleIndex = Read-RequiredText (Join-Path $backendRoot 'scripts/admin-test-env-modules/README.md') +$scriptsIndex = Read-RequiredText (Join-Path $backendRoot 'scripts/README.md') +Assert-Contains $adminAcceptanceEntry "const OPTIONAL_MODULES = ['manual-order-splitting-fixture'" '人工验收可选模块注册' +Assert-Contains $adminAcceptanceModuleIndex '人工分柜通过后可继续执行 `manual-preload-push-fixture.mjs`' '人工分柜到预装链路' +Assert-Contains $adminAcceptanceModuleIndex 'PDA 待复核通过后,`manual-loading-complete-fixture.mjs`' '人工 PDA 到 LOADED 链路' +Assert-Contains $scriptsIndex 'manual-*-fixture/verify' '人工分段验收根索引' + +# 同步 RestClient Trace 必须注册为公共自动配置,不能只有孤立拦截器源码。 +$commonWebAutoConfigurations = Read-RequiredText (Join-Path $backendRoot 'common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports') +Assert-Contains $commonWebAutoConfigurations 'com.baiyue.common.web.RestClientTracePropagationAutoConfiguration' 'RestClient Trace 自动配置注册' + +# 全部仓库主档统一归国内 Warehouse;海外服务只保存 warehouseId 引用和物理资源。 +$warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md') +$overseasWarehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md') +$domesticWarehouseService = Read-RequiredText (Join-Path $backendRoot 'warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java') +$domesticWarehouseController = Read-RequiredText (Join-Path $backendRoot 'warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java') +$overseasWarehouseSql = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/src/main/resources/db/warehouse.sql') +$adminIamApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/iam/api/platformIam.ts') +Assert-Contains $backendAgents '全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`' '后端仓库主档权威' +Assert-Contains $warehouseAgents '/internal/warehouses/shared-overseas' '国内 Warehouse 共享海外目录' +Assert-Contains $overseasWarehouseAgents '只保存国内仓库 ID 引用' '海外 Warehouse 引用边界' +Assert-Contains $domesticWarehouseController '@GetMapping("/shared-overseas")' '共享海外仓列表入口' +Assert-Contains $domesticWarehouseService 'hasReceivingCapability' '共享海外收货能力筛选' +Assert-Contains $adminIamApi "capabilityCode: 'RECEIVING'" 'Admin 海外收货仓候选' +Assert-NotContains $overseasWarehouseSql 'CREATE TABLE IF NOT EXISTS wh_warehouse' '海外仓库主档表' +Assert-NotContains $overseasWarehouseSql 'CREATE TABLE IF NOT EXISTS wh_warehouse_address' '海外仓库地址表' + +# 国内出仓按真实货柜原子确认,船节点实际进度只允许严格向后推进。 +$outboundDispatchOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml') +$carrierScheduleOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml') +$adminContainerDetail = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/pages/ContainerDetailView.vue') +Assert-Contains $outboundDispatchOpenApi 'No partial success is possible.' '整柜出仓原子性' +Assert-Contains $outboundDispatchOpenApi 'truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status' '拖车状态非门禁语义' +Assert-Contains $carrierScheduleOpenApi 'strictly greater standard-node sequence than latestActualNode' '手工实际节点单调推进' +Assert-Contains $carrierScheduleOpenApi 'estimated facts never advance actual progress' '预计节点不推进实际进度' +Assert-Contains $adminContainerDetail 'confirmDeparture' 'Admin 货柜详情确认离仓' + +# 平台商品图片和海外 PDA 签收照片都必须走鉴权下载,不暴露内部存储路径。 +$productImageController = Read-RequiredText (Join-Path $backendRoot 'file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java') +$receivingController = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java') +Assert-Contains $productImageController '/api/platform/v1/files/product-images' '平台商品图片入口' +Assert-Contains $productImageController "hasAuthority('platform:order:list')" '平台商品图片权限' +Assert-Contains $receivingController '/current/unload-photos/files/{fileId}/download' '海外 PDA 签收照片预览' +Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收照片缓存边界' + +# Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 +[xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw +foreach ($module in $pom.project.modules.module) { + Assert-Contains $backendAgents "$module/" '后端 AGENTS.md' +} + +# 对本轮高变化链路做精确文件门禁,防止改名或删除后留下悬空索引。 +$criticalPaths = @( + 'apps/admin-web/src/modules/overseas-warehouse/api/inboundOrders.ts', + 'apps/admin-web/src/modules/overseas-warehouse/api/inboundPlans.ts', + 'apps/admin-web/src/modules/overseas-warehouse/api/inventory.ts', + 'apps/admin-web/src/modules/overseas-warehouse/api/pallets.ts', + 'apps/admin-web/src/modules/overseas-warehouse/api/dispatches.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/DispatchesView.vue', + 'apps/admin-web/src/modules/overseas-warehouse/pages/DispatchDetailView.vue', + 'apps/admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue', + 'apps/admin-web/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue', + 'apps/admin-web/src/modules/container-loading/pages/ContainerDetailView.vue', + 'apps/admin-web/src/modules/container-loading/components/ContainerOverviewPanel.vue', + 'apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts', + 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', + 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue', + 'apps/admin-web/src/core/routing/navigationRecovery.ts', + 'apps/admin-web/src/core/routing/navigationRecovery.test.ts', + 'apps/admin-web/src/modules/overseas-warehouse/api/outbounds.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/OutboundsView.vue', + 'apps/admin-web/src/modules/overseas-warehouse/api/deliveryOrders.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue', + 'apps/admin-web/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue', + 'apps/admin-web/src/modules/overseas-warehouse/api/exceptionOrders.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/ExceptionOrdersView.vue', + 'apps/admin-web/src/modules/overseas-warehouse/api/customerVisits.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/CustomerVisitsView.vue', + '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/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', + 'apps/admin-web/src/modules/overseas-business/api/platformOverseasBusiness.ts', + 'apps/admin-web/src/modules/overseas-master-data/api/overseasMasterData.ts', + 'apps/pda-uniapp/pages/tasks/tasks.vue', + 'apps/pda-uniapp/config/runtime.js', + 'apps/pda-uniapp/.env.example', + 'apps/pda-uniapp/vite.config.js', + 'apps/pda-uniapp/utils/app-update.js', + 'apps/pda-uniapp/tests/app-update.test.mjs', + 'apps/pda-uniapp/utils/evidence-upload-queue.js', + 'apps/pda-overseas-uniapp/package.json', + 'apps/pda-overseas-uniapp/src/pages.json', + 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js', + 'apps/pda-overseas-uniapp/src/pages/operation/operation.vue', + 'apps/pda-overseas-uniapp/src/stores/receiving.js', + 'apps/pda-overseas-uniapp/tests/receiving-photo-ui-contract.test.mjs', + 'apps/pda-overseas-uniapp/src/config/runtime.js', + 'apps/pda-overseas-uniapp/src/api/auth.js', + 'apps/pda-overseas-uniapp/src/api/inbound.js', + 'apps/pda-overseas-uniapp/src/api/receiving-adapter.js', + 'apps/pda-overseas-uniapp/src/api/overseas.js', + 'apps/pda-overseas-uniapp/src/utils/scan-broadcast.js', + 'apps/pda-overseas-uniapp/src/manifest.json', + 'apps/pda-overseas-uniapp/tests/app-icons.test.mjs', + 'apps/pda-overseas-uniapp/src/utils/app-update.js', + 'apps/pda-overseas-uniapp/tests/app-update.test.mjs', + 'apps/pda-overseas-uniapp/src/i18n/index.js', + 'apps/pda-overseas-uniapp/src/i18n/locales/zh-CN.js', + 'apps/pda-overseas-uniapp/src/i18n/locales/en.js', + 'apps/pda-overseas-uniapp/src/i18n/locales/es.js', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java', + 'baiyue-firstleg/firstleg/docs/firstleg-internal-modularization-baseline.md', + '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/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', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + '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-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/inboundplan/pda/service/ReceivingContainerService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java', + 'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/DomesticWarehouseDirectoryClient.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/DomesticWarehouseDirectory.java', + 'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java', + 'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/dto/SharedOverseasWarehouseResponse.java', + 'baiyue-firstleg/file/src/main/java/com/baiyue/file/controller/PlatformProductImageController.java', + 'baiyue-firstleg/lastleg-service/docs/lastleg-internal-modularization-baseline.md', + 'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.mjs', + 'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs', + 'baiyue-firstleg/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java', + '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/FirstLegContainerShippingNodeRecordedV1.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java', + 'baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegInboundOrderMapper.java', + 'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java', + 'baiyue-firstleg/deploy/scripts/deploy-built-service.sh', + 'baiyue-firstleg/scripts/deploy-built-service-rollback.test.mjs', + 'baiyue-firstleg/scripts/java-quality-summary.mjs', + 'baiyue-firstleg/scripts/java-quality-summary.test.mjs', + 'baiyue-firstleg/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs', + 'baiyue-firstleg/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs', + 'baiyue-firstleg/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs', + 'baiyue-firstleg/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs', + 'baiyue-firstleg/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs', + 'docs/四期/船节点标准化契约.md', + 'docs/一期/一期头程订单闭环设计.md', + 'docs/二期/二期需求优化版-v2-测量计费入库主流程.md', + 'docs/墨家设备测量上传接口说明-V3.md', + 'docs/三期/三期需求确认表.md', + 'docs/统一订单主状态与操作契约.md', + 'docs/财务管理/财务管理产品确认表.md', + 'docs/财务管理/Firstleg-Finance跨服务契约.md' +) +$criticalPaths | ForEach-Object { Assert-Path $_ } + +foreach ($removedOverseasWarehouseMasterPath in @( + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java' +)) { + Assert-PathMissing $removedOverseasWarehouseMasterPath +} + +# 只检查明确维护的快速索引表,避免把排除规则正文中的目录名误判为业务入口。 +$indexedAgentFiles = @( + $rootAgentsPath, + $adminAgentsPath, + $clientAgentsPath, + $pdaAgentsPath, + $overseasPdaAgentsPath, + $backendAgentsPath, + (Join-Path $backendRoot 'firstleg/AGENTS.md'), + (Join-Path $backendRoot 'finance/AGENTS.md'), + (Join-Path $backendRoot 'lastleg-service/AGENTS.md'), + (Join-Path $backendRoot 'iam/AGENTS.md'), + (Join-Path $backendRoot 'common-security/AGENTS.md'), + (Join-Path $backendRoot 'common-web/AGENTS.md'), + (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') +) +$excludedTargetPattern = '(?i)`(?:[^`]*/)?(node_modules(?:\.[^/`]*)?|dist|target|reports|output|outputs|uni_modules|\.git|\.codex[^/`]*|\.runtime[^/`]*)(?:/|`)' +$excludedPatternSamples = @( + '| 错误入口 | `node_modules/pkg/` |', + '| 错误入口 | `node_modules.corrupt/.bin/vite` |', + '| 错误入口 | `src/target/classes/` |', + '| 错误入口 | `.codex-temp/task/` |', + '| 错误入口 | `.runtime-tailleg/DbQuery.java` |' +) +foreach ($sample in $excludedPatternSamples) { + $script:checked++ + if ($sample -notmatch $excludedTargetPattern) { + Add-Failure "排除目录检测器自检失败: $sample" + } +} +foreach ($agentFile in $indexedAgentFiles) { + $script:checked++ + if (-not (Test-Path -LiteralPath $agentFile -PathType Leaf)) { + Add-Failure "缺少受检索引文件: $agentFile" + continue + } + $lineNumber = 0 + foreach ($line in Get-Content -LiteralPath $agentFile) { + $lineNumber++ + if ($line.StartsWith('|') -and $line -match $excludedTargetPattern) { + Add-Failure "快速索引引用了排除目录: $agentFile`:$lineNumber" + } + } +} + +if ($failures.Count -gt 0) { + Write-Host "AGENTS 索引合同失败,共 $($failures.Count) 项:" -ForegroundColor Red + $failures | ForEach-Object { Write-Host "- $_" -ForegroundColor Red } + exit 1 +} + +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green +exit 0 diff --git a/scripts/client-phase2-test-env-acceptance.mjs b/scripts/client-phase2-test-env-acceptance.mjs index b3981a78a..1815ee4d5 100644 --- a/scripts/client-phase2-test-env-acceptance.mjs +++ b/scripts/client-phase2-test-env-acceptance.mjs @@ -19,7 +19,7 @@ const args = parseArgs(process.argv.slice(2)) const gateway = trimSlash(args.gateway ?? process.env.CLIENT_PHASE2_TEST_GATEWAY ?? 'http://8.163.30.128:9000') const clientUsername = args['client-username'] ?? process.env.CLIENT_PHASE2_USERNAME - ?? await promptText('二期客户端账号', 'merchant_admin') + ?? await promptText('二期客户端账号(必须由正式注册/建档产生)', '') const clientPassword = args['client-password'] ?? process.env.CLIENT_PHASE2_PASSWORD ?? await promptSecret('二期客户端密码') const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME @@ -30,8 +30,8 @@ const forcedOrderId = args['order-id'] ?? process.env.CLIENT_PHASE2_ORDER_ID ?? const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_PHASE2_REPORT_DIR ?? 'reports') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_PHASE2_TIMEOUT_MS ?? 30_000) -if (!clientPassword || !platformPassword) { - console.error('Client and platform passwords are required') +if (!clientUsername || !clientPassword || !platformPassword) { + console.error('An onboarded Client account and platform credentials are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 034057e97..b7afa8553 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -24,14 +24,14 @@ const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERN const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD ?? await promptSecret('平台审核密码') const existingClientUsername = args['existing-client-username'] ?? process.env.EXISTING_CLIENT_USERNAME - ?? await promptText('跨客户隔离测试账号', 'merchant_admin') + ?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '') const existingClientPassword = args['existing-client-password'] ?? process.env.EXISTING_CLIENT_PASSWORD ?? await promptSecret('跨客户隔离测试密码') const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) -if (!platformPassword) { - console.error('Platform password is required') +if (!platformPassword || !existingClientUsername || !existingClientPassword) { + console.error('Platform password and an independently onboarded client account are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index bd941c9cd..9ee3dd23c 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -11,6 +11,18 @@ test('coordinator releases its builder while waiting for downstream jobs', () => assert.match(pipeline, /agent \{ label 'java-builder' \}/) assert.doesNotMatch(pipeline, /agent any/) assert.doesNotMatch(pipeline, /cleanWs\(/) + assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m) +}) + +test('coordinator runs selected tests once before parallel service packaging', () => { + const gateIndex = pipeline.indexOf("stage('统一 Maven 测试门禁')") + const dispatchIndex = pipeline.indexOf("stage('并行调度独立服务 Job')") + + assert.ok(gateIndex >= 0, 'unified test gate must exist') + assert.ok(dispatchIndex > gateIndex, 'unified tests must finish before downstream dispatch') + assert.match(pipeline, /-pl \$\{selectedModules\} -am test/) + assert.match(pipeline, /booleanParam\(name: 'SKIP_TESTS', value: true\)/) + assert.doesNotMatch(pipeline, /TESTED_COMMIT/) }) test('AUTO detects database migrations without expanding them to all services', () => { @@ -29,3 +41,14 @@ test('coordinator cannot initialize test databases', () => { assert.doesNotMatch(pipeline, /initialize-test-databases\.sh/) assert.doesNotMatch(pipeline, /测试数据库初始化/) }) + +test('coordinator defaults event bridge deployments to both non-production 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.match(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\)/) +}) diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql new file mode 100644 index 000000000..81332bff9 --- /dev/null +++ b/scripts/db/incremental.sql @@ -0,0 +1,3811 @@ +-- Baiyue last-leg platform incremental database archive +-- Encoding: UTF-8 without BOM +-- Policy: +-- 1. Append new numbered sections; never rewrite an applied section. +-- 2. DDL must be repeatable and must preserve existing business rows. +-- 3. Every new column and index must use an information_schema guard. +-- 4. Do not place test fixtures, DELETE, TRUNCATE or business-data UPDATE here. + +-- ============================================================================ +-- 2026-08-15 / 001 +-- Event Bridge inbox and delivery-attempt baseline +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge; + +CREATE TABLE IF NOT EXISTS bridge_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', + lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', + retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) 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 COMMENT='跨区域事件已接收箱'; + +CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + attempt_no INT NOT NULL COMMENT '投递序号', + status VARCHAR(32) NOT NULL COMMENT '投递结果', + response_code VARCHAR(64) NULL COMMENT '对端响应码', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + started_at DATETIME(3) NOT NULL COMMENT '开始时间', + finished_at DATETIME(3) 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 COMMENT='跨区域事件投递尝试记录'; + +-- Upgrade an existing bridge_inbox without replacing payload JSON or other rows. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='lease_owner'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN lease_owner VARCHAR(128) NULL COMMENT ''消费租约持有者'' AFTER status')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 002 +-- Warehouse locations, pallets, resource locks and UTF-8 descriptions +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastlag_warehouse; + +CREATE TABLE IF NOT EXISTS wh_location ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + remark VARCHAR(500) NULL COMMENT '备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_location_code (tenant_id,warehouse_id,location_code,deleted), + KEY idx_location_query (tenant_id,warehouse_id,zone_code,status,deleted) +) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; + +CREATE TABLE IF NOT EXISTS wh_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + remark VARCHAR(500) NULL COMMENT '备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_code (tenant_id,warehouse_id,pallet_code,deleted), + KEY idx_pallet_query (tenant_id,warehouse_id,status,deleted), + KEY idx_pallet_location (tenant_id,warehouse_id,location_id,deleted) +) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; + +CREATE TABLE IF NOT EXISTS wh_resource_lock ( + id BIGINT UNSIGNED NOT NULL COMMENT '资源锁ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的托盘ID', + location_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的库位ID', + business_type VARCHAR(50) NOT NULL COMMENT '调用方业务类型', + business_id VARCHAR(100) NOT NULL COMMENT '调用方业务标识', + reservation_key VARCHAR(160) NOT NULL COMMENT '调用方资源预留幂等键', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态:ACTIVE有效、RELEASED释放、SCRAPPED报废', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_at DATETIME(3) 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 (id), + UNIQUE KEY uk_resource_lock_reservation (tenant_id,reservation_key), + KEY idx_resource_lock_pallet (tenant_id,warehouse_id,pallet_id,status), + KEY idx_resource_lock_location (tenant_id,warehouse_id,location_id,status), + KEY idx_resource_lock_business (tenant_id,business_type,business_id) +) ENGINE=InnoDB COMMENT='仓库托盘与库位资源锁表:支持跨服务幂等预留和释放'; + +-- Fix legacy mojibake/English descriptions without changing row values. +ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; +ALTER TABLE wh_location + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; +ALTER TABLE wh_pallet + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_resource_lock COMMENT='仓库托盘与库位资源锁表:支持跨服务幂等预留和释放'; +ALTER TABLE wh_resource_lock + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '资源锁ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN pallet_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的托盘ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的库位ID', + MODIFY COLUMN business_type VARCHAR(50) NOT NULL COMMENT '调用方业务类型', + MODIFY COLUMN business_id VARCHAR(100) NOT NULL COMMENT '调用方业务标识', + MODIFY COLUMN reservation_key VARCHAR(160) NOT NULL COMMENT '调用方资源预留幂等键', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态:ACTIVE有效、RELEASED释放、SCRAPPED报废', + MODIFY COLUMN locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + MODIFY COLUMN released_at DATETIME(3) NULL COMMENT '释放或报废时间', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +-- Guard every operational index for existing installations. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_location' AND index_name='uk_location_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_location_code ON wh_location(tenant_id,warehouse_id,location_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_location' AND index_name='idx_location_query'), 'SELECT 1', 'CREATE INDEX idx_location_query ON wh_location(tenant_id,warehouse_id,zone_code,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='uk_pallet_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_pallet_code ON wh_pallet(tenant_id,warehouse_id,pallet_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='idx_pallet_query'), 'SELECT 1', 'CREATE INDEX idx_pallet_query ON wh_pallet(tenant_id,warehouse_id,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='idx_pallet_location'), 'SELECT 1', 'CREATE INDEX idx_pallet_location ON wh_pallet(tenant_id,warehouse_id,location_id,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='uk_resource_lock_reservation'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_resource_lock_reservation ON wh_resource_lock(tenant_id,reservation_key)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_pallet'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_pallet ON wh_resource_lock(tenant_id,warehouse_id,pallet_id,status)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_location'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_location ON wh_resource_lock(tenant_id,warehouse_id,location_id,status)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_business'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_business ON wh_resource_lock(tenant_id,business_type,business_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 003 +-- Last-leg inbound plan, pallet allocation and PDA workflow +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan ( + id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '入库计划状态', + line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + total_volume_cbm DECIMAL(18,4) 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_plan_no (plan_no), + UNIQUE KEY uk_ll_inbound_plan_container (tenant_id,container_no) +) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( + id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', + required_package_count INT NOT NULL DEFAULT 0 COMMENT '计划应收箱数', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_line_order (plan_id,order_id), + KEY idx_ll_inbound_line_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( + id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', + warehouse_pallet_id BIGINT NULL COMMENT '仓库托盘ID', + warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', + warehouse_resource_lock_id BIGINT NULL COMMENT '仓库资源锁ID', + assignment_status VARCHAR(32) NOT NULL DEFAULT 'UNASSIGNED' COMMENT '仓库资源分配状态', + locked_at DATETIME(3) NULL COMMENT '资源锁定时间', + actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '实际入托体积(立方米)', + actual_package_count INT NOT NULL DEFAULT 0 COMMENT '实际入托箱数', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), + UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id,sequence_no), + KEY idx_ll_inbound_pallet_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( + id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '计划分配体积(立方米)', + planned_package_count INT NOT NULL DEFAULT 0 COMMENT '计划分配箱数', + counted_package_count INT NOT NULL DEFAULT 0 COMMENT '清点箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT '上架箱数', + actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '实际分配体积(立方米)', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_COUNT' COMMENT 'PDA作业状态', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_allocation (line_id,pallet_id), + KEY idx_ll_inbound_allocation_plan (plan_id), + KEY idx_ll_inbound_allocation_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; + +-- Columns introduced after the initial inbound-plan release. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划应收箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库托盘ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL COMMENT ''仓库库位ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''仓库资源锁ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED'' COMMENT ''仓库资源分配状态''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''资源锁定时间''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实际入托体积(立方米)''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0 COMMENT ''实际入托箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划分配箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0 COMMENT ''清点箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''上架箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实际分配体积(立方米)''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT'' COMMENT ''PDA作业状态''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( + id BIGINT NOT NULL COMMENT 'PDA任务ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + operator_name VARCHAR(255) NULL COMMENT '操作人名称', pushed_at DATETIME(3) NULL COMMENT '推送时间', + started_at DATETIME(3) NULL COMMENT '开始时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + version INT 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_task_plan (tenant_id,plan_id), + UNIQUE KEY uk_ll_inbound_pda_task_no (task_no) +) ENGINE=InnoDB COMMENT='尾程入库PDA任务表'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( + id BIGINT NOT NULL COMMENT 'PDA操作记录ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NULL COMMENT '托盘分配记录ID', + action VARCHAR(32) NOT NULL COMMENT '操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求摘要', request_json LONGTEXT NOT NULL COMMENT '请求JSON', + result_json LONGTEXT NOT NULL COMMENT '结果JSON', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_operation_key (tenant_id,idempotency_key), + KEY idx_ll_inbound_pda_operation_task (task_id), KEY idx_ll_inbound_pda_operation_allocation (allocation_id) +) ENGINE=InnoDB COMMENT='尾程入库PDA幂等操作记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( + id BIGINT NOT NULL COMMENT '合托记录ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + task_id BIGINT NOT NULL COMMENT 'PDA任务ID', merge_group_no VARCHAR(64) NOT NULL COMMENT '合托组号', + source_pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', target_pallet_id BIGINT NOT NULL COMMENT '目标托盘ID', + source_allocation_id BIGINT NOT NULL COMMENT '来源分配ID', target_allocation_id BIGINT NOT NULL COMMENT '目标分配ID', + source_line_id BIGINT NOT NULL COMMENT '来源计划明细ID', target_line_id BIGINT NOT NULL COMMENT '目标计划明细ID', + source_order_id BIGINT NOT NULL COMMENT '来源订单ID', target_order_id BIGINT NOT NULL COMMENT '目标订单ID', + moved_package_count INT NOT NULL COMMENT '移动箱数', moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '移动体积(立方米)', + source_pallet_scrapped TINYINT NOT NULL DEFAULT 0 COMMENT '来源托盘是否报废', status VARCHAR(32) NOT NULL COMMENT '合托状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_inbound_merge_task (tenant_id,task_id), KEY idx_ll_inbound_merge_group (merge_group_no) +) ENGINE=InnoDB COMMENT='尾程入库PDA合托记录'; + +-- ============================================================================ +-- 2026-08-15 / 004 +-- Upgrade an existing Event Bridge inbox with retry/lease fields and indexes +-- ============================================================================ +USE baiyue_event_bridge; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='lease_until'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN lease_until DATETIME(3) NULL COMMENT ''消费租约截止时间'' AFTER lease_owner')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='retry_count'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT ''消费重试次数'' AFTER lease_until')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='next_retry_at'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN next_retry_at DATETIME(3) NULL COMMENT ''下次消费时间'' AFTER retry_count')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='last_error'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN last_error VARCHAR(1000) NULL COMMENT ''最后错误'' AFTER next_retry_at')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND index_name='idx_bridge_inbox_consume'), 'SELECT 1', 'CREATE INDEX idx_bridge_inbox_consume ON bridge_inbox(status,next_retry_at,lease_until)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND index_name='uk_bridge_inbox_idempotency'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_bridge_inbox_idempotency ON bridge_inbox(idempotency_key)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 005 +-- FirstLegLoadingPlanCompleted -> last-leg order projection +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载SHA-256摘要', + payload_json JSON NOT NULL COMMENT '事件冻结负载JSON', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误', + PRIMARY KEY (event_id), + UNIQUE KEY uk_ll_loading_inbox_tenant_key (tenant_id,event_type,event_id) +) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_order ( + id BIGINT NOT NULL COMMENT '雪花ID', + order_no VARCHAR(32) NOT NULL COMMENT '尾程单号,同柜入仓单共享', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装LOADED分片ID,建单幂等键', + root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单ID', + source_order_id VARCHAR(128) NULL COMMENT '来源订单ID', + source_order_no VARCHAR(128) NULL COMMENT '来源订单号', + root_order_id VARCHAR(128) NULL COMMENT '根订单ID', + customer_name VARCHAR(255) NULL COMMENT '货主名称', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称', + transfer_warehouse_id VARCHAR(128) NULL COMMENT '转入目的仓ID', + transfer_warehouse_code VARCHAR(128) NULL COMMENT '转入目的仓编码', + transfer_warehouse_name VARCHAR(255) NULL COMMENT '转入目的仓名称', + fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + container_no VARCHAR(64) NULL COMMENT '实际集装箱号', + container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态', + abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + product_quantity INT NULL COMMENT '货物数量', + volume_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + actual_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + estimated_warehouse_at DATETIME(3) NULL COMMENT '预计到仓时间', + inbound_order_no VARCHAR(128) NULL COMMENT '入库单号', + outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + delivery_order_no VARCHAR(128) NULL COMMENT '派送单号', + relabel_order_no VARCHAR(128) NULL COMMENT '换标单号', + relabel_status VARCHAR(32) NULL COMMENT '换标状态', + pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记', + current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)', + customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)', + warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件ID', + source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划ID', + source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', + source_handover_version BIGINT NULL COMMENT '交接版本', + source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源入仓单快照摘要', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_order_source (tenant_id,source_region,source_inbound_id), + KEY idx_ll_order_group (tenant_id,order_no), + KEY idx_ll_order_list (tenant_id,current_status,created_at) +) ENGINE=InnoDB COMMENT='尾程订单主表'; + +CREATE TABLE IF NOT EXISTS ll_order_detail ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名', + product_name_en VARCHAR(255) NULL COMMENT '英文品名', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + product_quantity INT NULL COMMENT '数量', + pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,4) NULL COMMENT '毛重(千克)', + net_weight_kg DECIMAL(18,4) NULL COMMENT '净重(千克)', + dimensional_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + fba_tracking_no VARCHAR(255) NULL COMMENT 'FBA跟踪号', + shipment_no VARCHAR(255) NULL COMMENT '货件编号', + shipping_mark VARCHAR(255) NULL COMMENT '唛头', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_detail_source (order_id,source_inbound_detail_id), + KEY idx_ll_detail_order (order_id) +) ENGINE=InnoDB COMMENT='尾程订单实装商品冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_loading_order_status_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + event_id VARCHAR(128) NULL COMMENT '触发事件ID', + remark VARCHAR(500) NULL COMMENT '状态变更备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间', + PRIMARY KEY (id), + KEY idx_ll_loading_history_customer_order (customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单状态变更历史'; + +-- Existing order installations: add transfer fields without replacing row data. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT ''转入目的仓ID'' AFTER overseas_warehouse_name')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT ''转入目的仓编码'' AFTER transfer_warehouse_id')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT ''转入目的仓名称'' AFTER transfer_warehouse_code')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- One container/loading-plan number groups multiple inbound-code rows, so order_no +-- must not be unique. Source inbound identity remains uniquely constrained. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='uk_ll_order_no' AND non_unique=0), 'ALTER TABLE ll_order DROP INDEX uk_ll_order_no', 'SELECT 1')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_group'), 'SELECT 1', 'CREATE INDEX idx_ll_order_group ON ll_order(tenant_id,order_no)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='uk_ll_order_source'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_ll_order_source ON ll_order(tenant_id,source_region,source_inbound_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_list'), 'SELECT 1', 'CREATE INDEX idx_ll_order_list ON ll_order(tenant_id,current_status,created_at)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND index_name='uk_ll_loading_inbox_tenant_key'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_ll_loading_inbox_tenant_key ON ll_loading_event_inbox(tenant_id,event_type,event_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 006 +-- Warehouse master-data descriptions and query indexes +-- ============================================================================ + +USE baiyue_lastlag_warehouse; + +CREATE TABLE IF NOT EXISTS wh_warehouse ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', contact_name VARCHAR(64) NULL COMMENT '仓库联系人', + contact_phone VARCHAR(32) NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', remark VARCHAR(500) NULL COMMENT '仓库备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_wh_tenant_code (tenant_id,warehouse_code,deleted), + KEY idx_wh_tenant_status (tenant_id,status,deleted), KEY idx_wh_country (tenant_id,country_code,deleted) +) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; + +CREATE TABLE IF NOT EXISTS wh_warehouse_address ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货', + country_code CHAR(2) NOT NULL COMMENT '国家二字码', state_province VARCHAR(100) NULL COMMENT '省或州', + city VARCHAR(100) NOT NULL COMMENT '城市', district VARCHAR(100) NULL COMMENT '区县', postal_code VARCHAR(20) NULL COMMENT '邮政编码', + address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', address_line2 VARCHAR(255) NULL COMMENT '补充地址行', + latitude DECIMAL(10,7) NULL COMMENT '纬度', longitude DECIMAL(10,7) NULL COMMENT '经度', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_address_type (tenant_id,warehouse_id,address_type,deleted), + KEY idx_address_warehouse (tenant_id,warehouse_id,deleted) +) ENGINE=InnoDB COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; + +CREATE TABLE IF NOT EXISTS wh_service_capability ( + id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', capability_code VARCHAR(50) NOT NULL COMMENT '能力编码', + capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + cutoff_time TIME NULL COMMENT '当日作业截单时间', description VARCHAR(500) NULL COMMENT '能力说明', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_capability (tenant_id,warehouse_id,capability_code,deleted), + KEY idx_capability_query (tenant_id,warehouse_id,enabled,deleted) +) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; + +ALTER TABLE wh_warehouse COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; +ALTER TABLE wh_warehouse + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', MODIFY COLUMN warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', MODIFY COLUMN contact_name VARCHAR(64) NULL COMMENT '仓库联系人', + MODIFY COLUMN contact_phone VARCHAR(32) NULL COMMENT '联系电话', MODIFY COLUMN contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '仓库备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_warehouse_address COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; +ALTER TABLE wh_warehouse_address + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货', + MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '国家二字码', MODIFY COLUMN state_province VARCHAR(100) NULL COMMENT '省或州', + MODIFY COLUMN city VARCHAR(100) NOT NULL COMMENT '城市', MODIFY COLUMN district VARCHAR(100) NULL COMMENT '区县', + MODIFY COLUMN postal_code VARCHAR(20) NULL COMMENT '邮政编码', MODIFY COLUMN address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + MODIFY COLUMN address_line2 VARCHAR(255) NULL COMMENT '补充地址行', MODIFY COLUMN latitude DECIMAL(10,7) NULL COMMENT '纬度', + MODIFY COLUMN longitude DECIMAL(10,7) NULL COMMENT '经度', MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_service_capability COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; +ALTER TABLE wh_service_capability + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING收货、STORAGE仓储、PICK_PACK拣包、RETURN退货', + MODIFY COLUMN capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + MODIFY COLUMN cutoff_time TIME NULL COMMENT '当日作业截单时间', MODIFY COLUMN description VARCHAR(500) NULL COMMENT '能力说明', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='uk_wh_tenant_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_wh_tenant_code ON wh_warehouse(tenant_id,warehouse_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='idx_wh_tenant_status'), 'SELECT 1', 'CREATE INDEX idx_wh_tenant_status ON wh_warehouse(tenant_id,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='idx_wh_country'), 'SELECT 1', 'CREATE INDEX idx_wh_country ON wh_warehouse(tenant_id,country_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND index_name='uk_address_type'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_address_type ON wh_warehouse_address(tenant_id,warehouse_id,address_type,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND index_name='idx_address_warehouse'), 'SELECT 1', 'CREATE INDEX idx_address_warehouse ON wh_warehouse_address(tenant_id,warehouse_id,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND index_name='uk_capability'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_capability ON wh_service_capability(tenant_id,warehouse_id,capability_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND index_name='idx_capability_query'), 'SELECT 1', 'CREATE INDEX idx_capability_query ON wh_service_capability(tenant_id,warehouse_id,enabled,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-17 / 007 +-- Last-leg batch FBA appointments +-- ============================================================================ + +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.fba_warehouse_code=o.fba_warehouse_code +WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( + id BIGINT NOT NULL COMMENT '主键ID', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', + appointment_at DATETIME(3) NOT NULL COMMENT '预约时间', + appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', + appointment_remark VARCHAR(1000) NULL COMMENT '预约备注', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + selected_count INT NOT NULL DEFAULT 0 COMMENT '已选明细数量', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_fba_appointment_no (tenant_id, appointment_no), + KEY idx_ll_fba_appointment_list (tenant_id, appointment_status, fba_warehouse_code, created_at) +) ENGINE=InnoDB COMMENT='FBA入仓预约单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( + id BIGINT NOT NULL COMMENT '主键ID', + appointment_id BIGINT NOT NULL COMMENT '预约单ID', + plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', + order_id BIGINT NOT NULL COMMENT '订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id), + KEY idx_ll_fba_appointment_item_plan (plan_id), + KEY idx_ll_fba_appointment_item_order (order_id) +) ENGINE=InnoDB COMMENT='FBA入仓预约明细'; + +-- ============================================================================ +-- 2026-08-17 / 008 +-- Last-leg inbound-order/package inventory balances, positions and immutable ledger +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inventory_balance ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外仓编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', + allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', + in_transit_packages BIGINT NOT NULL DEFAULT 0 COMMENT '在途箱数', receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + procurement_packages BIGINT NOT NULL DEFAULT 0 COMMENT '采购业务占用箱数', 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code), + KEY idx_ll_inventory_list (tenant_id,overseas_warehouse_id,updated_at), + KEY idx_ll_inventory_inbound (tenant_id,inbound_code) +) ENGINE=InnoDB COMMENT='入仓单维度实时库存余额'; + +CREATE TABLE IF NOT EXISTS ll_inventory_position ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + pallet_id BIGINT NOT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', + batch_no VARCHAR(128) NOT NULL DEFAULT '' COMMENT '库存批次号', warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', + warehouse_location_code VARCHAR(128) NOT NULL COMMENT '仓库库位编码', + available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', + allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', + receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_position (balance_id,pallet_id,batch_no), + KEY idx_ll_inventory_position_pallet (tenant_id,pallet_id), + KEY idx_ll_inventory_position_location (tenant_id,overseas_warehouse_id,warehouse_location_code) +) ENGINE=InnoDB COMMENT='托盘库位批次库存明细'; + +CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', position_id BIGINT NULL COMMENT '库存仓位记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', + pallet_id BIGINT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NULL COMMENT '托盘号', batch_no VARCHAR(128) NULL COMMENT '库存批次号', + warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + business_order_id BIGINT NULL COMMENT '业务单据ID', business_order_no VARCHAR(128) NULL COMMENT '业务单号', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + source_event_id VARCHAR(128) NULL COMMENT '来源事件ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + status_from VARCHAR(32) NULL COMMENT '操作前状态', status_to VARCHAR(32) NULL COMMENT '操作后状态', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', remark VARCHAR(1000) NULL COMMENT '备注', + occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_ledger_idempotency (tenant_id,idempotency_key), + KEY idx_ll_inventory_ledger_query (tenant_id,overseas_warehouse_id,occurred_at), + KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at), + KEY idx_ll_inventory_ledger_business (tenant_id,business_type,business_order_no) +) ENGINE=InnoDB COMMENT='不可变库存变动流水'; + +CREATE TABLE IF NOT EXISTS ll_inventory_operation ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING' COMMENT '库存命令处理状态', + result_json LONGTEXT NULL COMMENT '处理结果JSON', last_error VARCHAR(2000) 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 (id), UNIQUE KEY uk_ll_inventory_operation_key (tenant_id,idempotency_key), + KEY idx_ll_inventory_operation_status (tenant_id,status,updated_at) +) ENGINE=InnoDB COMMENT='库存命令幂等记录'; + +-- ============================================================================ +-- 2026-08-17 / 009 +-- PDA SKU/pallet receipt snapshots for stock-in closure +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + plan_id BIGINT NOT NULL COMMENT '入仓计划ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NOT NULL COMMENT '分配记录ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', sku_name_zh VARCHAR(255) NULL COMMENT 'SKU中文名称', source_detail_ids VARCHAR(2000) NULL COMMENT '来源明细ID集合', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', + warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', + batch_no VARCHAR(128) NOT NULL COMMENT '库存批次号', received_packages BIGINT NOT NULL COMMENT '实收箱数', received_quantity BIGINT NOT NULL COMMENT '实收产品数量', + inventory_balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', received_at DATETIME(3) NOT NULL COMMENT '实收时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (tenant_id,task_id,allocation_id,sku_code), + KEY idx_ll_stock_receipt_plan (tenant_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id) +) ENGINE=InnoDB COMMENT='入仓PDA库存实收快照'; + +-- 2026-08-17 warehouse virtual location and generated pallet code support. +USE baiyue_lastlag_warehouse; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='wh_pallet' AND column_name='pallet_code'), + 'ALTER TABLE wh_pallet MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''Warehouse pallet code''', 'SELECT 1')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='wh_pallet' AND column_name='fba_warehouse_code'), + 'ALTER TABLE wh_pallet MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码''', + 'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +INSERT INTO wh_location (id, tenant_id, warehouse_id, zone_code, location_code, location_type, + max_load_kg, status, remark) +SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'), + 'VIRTUAL', 999999999.000, 'ACTIVE', 'System default virtual location' +FROM wh_warehouse w +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location l + WHERE l.tenant_id = w.tenant_id AND l.warehouse_id = w.id + AND l.location_code = CONCAT('LOC-', w.warehouse_code, '-DEFAULT') AND l.deleted = 0 +); + +-- ============================================================================ +-- 2026-08-17 / 010 +-- Last-leg pre-dispatch orders and pallet locks +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_dispatch_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', driver_name VARCHAR(128) NULL COMMENT '司机姓名', + driver_phone VARCHAR(64) NULL COMMENT '司机电话', carrier_name VARCHAR(255) NULL COMMENT '承运商名称', route VARCHAR(255) NULL COMMENT '运输路线', + depart_at DATETIME(3) NULL COMMENT '发车时间', fba_appointment_no VARCHAR(64) NULL COMMENT 'FBA预约号', + fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', + vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + created_by VARCHAR(128) NULL COMMENT '创建人ID', 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_dispatch_order_no (tenant_id,dispatch_no), + KEY idx_ll_dispatch_order_list (tenant_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle (tenant_id,vehicle_plate) +) ENGINE=InnoDB COMMENT='尾程派车单'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_item ( + id BIGINT NOT NULL COMMENT '主键ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + plan_id BIGINT NULL COMMENT '入仓计划ID', plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + order_id BIGINT NULL COMMENT '订单ID', order_no VARCHAR(64) NULL COMMENT '订单号', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', + source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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 (tenant_id,order_id), KEY idx_ll_dispatch_item_inbound (tenant_id,inbound_code), + KEY idx_ll_dispatch_item_pallet (tenant_id,plan_pallet_id,warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='派车货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', + plan_id BIGINT NULL COMMENT '入仓计划ID', plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', released_at DATETIME(3) 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 (id), UNIQUE KEY uk_ll_dispatch_active_pallet (tenant_id,active_key), + KEY idx_ll_dispatch_pallet_lock_order (tenant_id,dispatch_order_id,status), + KEY idx_ll_dispatch_pallet_lock_source (tenant_id,source_pallet_key,status) +) ENGINE=InnoDB COMMENT='派车托盘排他锁'; + +-- ============================================================================ +-- 2026-08-17 / 011 +-- Dispatch PDA task and immutable pallet pick operations +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NULL COMMENT '操作人姓名', warehouse_id VARCHAR(128) NULL COMMENT '仓库ID', + pushed_at DATETIME(3) NOT NULL COMMENT '任务下发时间', completed_at DATETIME(3) 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 (id), UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (tenant_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_no (tenant_id,task_no) +) ENGINE=InnoDB COMMENT='派车PDA拣货任务'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + picked_at DATETIME(3) NOT NULL COMMENT '拣货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (tenant_id,idempotency_key), + KEY idx_ll_dispatch_pda_pick_item (tenant_id,task_id,dispatch_item_id) +) ENGINE=InnoDB COMMENT='派车PDA拣货记录'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', task_id BIGINT NULL COMMENT 'PDA任务ID', + operation_type VARCHAR(32) NOT NULL COMMENT 'PDA操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + result_json LONGTEXT NULL COMMENT '处理结果JSON', status VARCHAR(32) NOT NULL COMMENT 'PDA操作处理状态', + 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_dispatch_pda_operation_key (tenant_id,idempotency_key) +) ENGINE=InnoDB COMMENT='派车PDA操作幂等记录'; + +-- ============================================================================ +-- 2026-08-17 / 012 +-- Preserve all tail-leg orders included in one merged dispatch pallet +-- ============================================================================ + +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-17 / 013 +-- Partial dispatch completion and cumulative package counters +-- ============================================================================ +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN overseas_warehouse_id VARCHAR(128) NULL AFTER dispatch_no')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL AFTER overseas_warehouse_id')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN overseas_warehouse_id VARCHAR(128) NULL AFTER pallet_no')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL AFTER overseas_warehouse_id')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pda_task ADD COLUMN warehouse_id VARCHAR(128) NULL AFTER operator_name')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 AFTER package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID', + receipt_id BIGINT NOT 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 '创建时间', + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (tenant_id,pick_id), + KEY idx_ll_dispatch_pick_order_receipt (tenant_id,receipt_id) +) ENGINE=InnoDB COMMENT='PDA拣货订单归属记录'; + +-- Tail order cumulative picking/shipping counters. Original package_count is immutable. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 AFTER package_count')); +PREPARE tail_order_partial_stmt FROM @ddl; EXECUTE tail_order_partial_stmt; DEALLOCATE PREPARE tail_order_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 AFTER picked_package_count')); +PREPARE tail_order_partial_stmt FROM @ddl; EXECUTE tail_order_partial_stmt; DEALLOCATE PREPARE tail_order_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 AFTER picked_package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN partial_completion_reason VARCHAR(1000) NULL AFTER shipped_package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; + +-- ============================================================================ +-- 2026-08-17 / 014 +-- Dispatch transfer to outbound and delivery documents +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_outbound_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_outbound_no (tenant_id,outbound_no), + UNIQUE KEY uk_ll_outbound_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (tenant_id,dispatch_order_id), + KEY idx_ll_outbound_list (tenant_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程出库单'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_outbound_pallet (tenant_id,outbound_order_id,warehouse_pallet_id), + KEY idx_ll_outbound_pallet_list (tenant_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (tenant_id,destination_warehouse_code) +) ENGINE=InnoDB COMMENT='出库托盘明细'; + +CREATE TABLE IF NOT EXISTS ll_delivery_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '业务状态', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_delivery_no (tenant_id,delivery_no), + UNIQUE KEY uk_ll_delivery_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_delivery_outbound (tenant_id,outbound_order_id), + KEY idx_ll_delivery_list (tenant_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程派送单'; + +-- ============================================================================ +-- 2026-08-17 / 015 +-- Change inventory ownership from SKU/quantity to inbound-order/package count. +-- Legacy SKU columns become nullable and quantity columns remain untouched so original data is preserved. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_id BIGINT NULL AFTER destination_warehouse_name')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_no VARCHAR(128) NULL AFTER inbound_order_id')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_code VARCHAR(128) NULL AFTER inbound_order_no')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +-- One legacy balance row represented one SKU. Keep each row intact and give it a +-- collision-free migration identity while retaining the best known inbound snapshots. +UPDATE ll_inventory_balance b +LEFT JOIN ( + SELECT balance_id, MAX(inbound_order_no) AS inbound_order_no, MAX(inbound_code) AS inbound_code + FROM ll_inventory_ledger GROUP BY balance_id +) l ON l.balance_id=b.id +SET b.inbound_order_id=COALESCE(b.inbound_order_id,b.id), + b.inbound_order_no=COALESCE(b.inbound_order_no,l.inbound_order_no), + b.inbound_code=COALESCE(NULLIF(b.inbound_code,''),NULLIF(l.inbound_code,''),CONCAT('LEGACY-',b.id)) +WHERE b.inbound_order_id IS NULL OR b.inbound_code IS NULL OR b.inbound_code=''; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'SELECT 1', CONCAT('ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'))); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_inbound'), 'SELECT 1', CONCAT('ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_inbound (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',inbound_code)'))); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'), 'ALTER TABLE ll_inventory_balance DROP INDEX idx_ll_inventory_sku', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN inbound_order_id BIGINT NULL AFTER destination_warehouse_code')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +UPDATE ll_inventory_ledger SET inbound_order_id=COALESCE(inbound_order_id,business_order_id,balance_id) WHERE inbound_order_id IS NULL; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at)')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_sku'), 'ALTER TABLE ll_inventory_ledger DROP INDEX idx_ll_inventory_ledger_sku', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +-- ============================================================================ +-- 2026-08-17 / 016 +-- PDA stock receipts follow the same inbound-order/package-only inventory model. +-- Legacy SKU and quantity columns remain in place but become nullable/defaulted. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_quantity'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_quantity BIGINT NULL DEFAULT 0', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'), 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation_sku', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'), 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; + +-- ============================================================================ +-- 2026-08-17 / 017 +-- Customer relabel applications, cargo snapshots, and process status history +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_relabel_application ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号', lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', + customer_order_no VARCHAR(128) NOT NULL COMMENT '客户订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', remark VARCHAR(1000) NULL COMMENT '备注', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING' COMMENT '业务状态', item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', + total_package_count INT NOT NULL DEFAULT 0 COMMENT '总箱数', notified_package_count INT NOT NULL DEFAULT 0 COMMENT '已通知箱数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', 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) +) ENGINE=InnoDB COMMENT='客户换标申请单'; + +CREATE TABLE IF NOT EXISTS ll_relabel_item ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_image_file_id VARCHAR(128) NULL COMMENT '产品图片文件ID', + product_image_file_name VARCHAR(255) NULL COMMENT '产品图片文件名', product_image_url VARCHAR(1000) NULL COMMENT '产品图片地址', + product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', total_package_count INT NOT NULL COMMENT '总箱数', notified_package_count INT NOT NULL COMMENT '已通知箱数', + shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号', tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', + label_requirement VARCHAR(1000) NULL COMMENT '换标要求', relabel_file_id VARCHAR(128) NULL COMMENT '换标文件ID', + relabel_file_name VARCHAR(255) NULL COMMENT '换标文件名', relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='换标货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', application_id BIGINT NOT NULL COMMENT '申请单ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='换标状态变更历史'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_no (tenant_id,relabel_no)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_idempotency'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_idempotency (tenant_id,idempotency_key)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_order'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_list'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_list (tenant_id,status,created_at)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_customer'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_customer (tenant_id,customer_order_no)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_warehouse'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_warehouse (tenant_id,overseas_warehouse_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_application'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_application (tenant_id,application_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_sku'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_sku (tenant_id,sku_code)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND index_name='idx_ll_relabel_status_history_application'), 'SELECT 1', 'ALTER TABLE ll_relabel_status_history ADD KEY idx_ll_relabel_status_history_application (tenant_id,application_id,created_at)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; + +-- ============================================================================ +-- 2026-08-17 / 018 +-- Existing-table correction: apply schema changes with ADD/UPDATE/ALTER guards +-- ============================================================================ +-- Sections 008-017 keep CREATE TABLE definitions for fresh databases. This +-- section is the authoritative path for databases that already contain those +-- tables. Legacy SKU/quantity columns are retained and made nullable; no +-- business rows or Outbox rows are deleted. +USE baiyue_lastleg; + +-- Tail orders: cumulative PDA picking/shipping counters. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数'' AFTER package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数'' AFTER picked_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Inventory balance: add inbound-order identity without dropping legacy SKU data. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_id BIGINT NULL COMMENT ''入仓单ID'' AFTER destination_warehouse_name')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_no VARCHAR(128) NULL COMMENT ''入库单号快照'' AFTER inbound_order_id')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓码快照'' AFTER inbound_order_no')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +UPDATE ll_inventory_balance +SET inbound_order_id = COALESCE(inbound_order_id, id), + inbound_code = COALESCE(NULLIF(inbound_code, ''), CONCAT('LEGACY-', id)) +WHERE inbound_order_id IS NULL OR inbound_code IS NULL OR inbound_code = ''; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'SELECT 1', CONCAT('ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'))); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'), 'ALTER TABLE ll_inventory_balance DROP INDEX idx_ll_inventory_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_inbound'), 'SELECT 1', CONCAT('ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_inbound (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',inbound_code)'))); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Inventory positions and ledger: preserve SKU columns, add inbound identity. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN inbound_order_id BIGINT NULL COMMENT ''入仓单ID'' AFTER destination_warehouse_code')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +UPDATE ll_inventory_ledger +SET inbound_order_id = COALESCE(inbound_order_id, business_order_id, balance_id) +WHERE inbound_order_id IS NULL; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_sku'), 'ALTER TABLE ll_inventory_ledger DROP INDEX idx_ll_inventory_ledger_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at)')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Stock receipts: keep legacy SKU/quantity snapshots but allow inbound-only rows. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_quantity'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_quantity BIGINT NULL DEFAULT 0', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'), 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'), 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Dispatch/PDA fields are also ADD operations for existing installations. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数'' AFTER package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数'' AFTER picked_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN partial_completion_reason VARCHAR(1000) NULL COMMENT ''部分完成原因'' AFTER shipped_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- No DROP COLUMN is included. Existing columns and business rows remain recoverable. + +-- ============================================================================ +-- 2026-08-17 / 019 +-- Warehouse transfer orders with historical customer-order/inbound bindings. +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', + operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式', + estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号', + tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', + need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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) +) ENGINE=InnoDB COMMENT='客户转仓单'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + source_lastleg_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID', source_lastleg_order_no VARCHAR(32) NOT NULL COMMENT '来源尾程订单号', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='转仓来源历史订单绑定'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', + package_count INT NOT NULL COMMENT '箱数', shipping_mark VARCHAR(255) NULL COMMENT '运输唛头', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='转仓货物冻结明细'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='uk_ll_wh_transfer_no'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD UNIQUE KEY uk_ll_wh_transfer_no (tenant_id,transfer_no)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='uk_ll_wh_transfer_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD UNIQUE KEY uk_ll_wh_transfer_key (tenant_id,idempotency_key)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='uk_ll_wh_transfer_tail_order'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD UNIQUE KEY uk_ll_wh_transfer_tail_order (lastleg_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='idx_ll_wh_transfer_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD KEY idx_ll_wh_transfer_list (tenant_id,status,created_at)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='idx_ll_wh_transfer_inbound'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD KEY idx_ll_wh_transfer_inbound (tenant_id,inbound_code)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND index_name='uk_ll_wh_transfer_source'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_source_order ADD UNIQUE KEY uk_ll_wh_transfer_source (transfer_order_id,source_lastleg_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND index_name='idx_ll_wh_transfer_source_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_source_order ADD KEY idx_ll_wh_transfer_source_list (tenant_id,transfer_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND index_name='idx_ll_wh_transfer_item_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD KEY idx_ll_wh_transfer_item_list (tenant_id,transfer_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND index_name='idx_ll_wh_transfer_item_sku'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD KEY idx_ll_wh_transfer_item_sku (tenant_id,sku_code)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; + +-- 2026-08-17 / customer naming and one-click dropship carton inventory. +-- Rename only existing columns; deployed data and index definitions remain intact. +DELIMITER // +CREATE PROCEDURE migrate_tenant_columns_to_customer() +BEGIN + DECLARE done INT DEFAULT FALSE; + DECLARE table_name_value VARCHAR(128); + DECLARE column_cursor CURSOR FOR + SELECT c.table_name FROM information_schema.columns c + WHERE c.table_schema=DATABASE() AND c.column_name='tenant_id' + AND NOT EXISTS (SELECT 1 FROM information_schema.columns c2 + WHERE c2.table_schema=c.table_schema AND c2.table_name=c.table_name AND c2.column_name='customer_id'); + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done=TRUE; + OPEN column_cursor; + rename_loop: LOOP + FETCH column_cursor INTO table_name_value; + IF done THEN LEAVE rename_loop; END IF; + SET @rename_sql=CONCAT('ALTER TABLE `', table_name_value, '` RENAME COLUMN tenant_id TO customer_id'); + PREPARE rename_statement FROM @rename_sql; + EXECUTE rename_statement; + DEALLOCATE PREPARE rename_statement; + END LOOP; + CLOSE column_cursor; +END// +DELIMITER ; +CALL migrate_tenant_columns_to_customer(); +DROP PROCEDURE migrate_tenant_columns_to_customer; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN sku_code VARCHAR(128) NULL AFTER batch_no')); +PREPARE inventory_sku_020 FROM @ddl; EXECUTE inventory_sku_020; DEALLOCATE PREPARE inventory_sku_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_mark'), 'SELECT 1', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_no'), 'ALTER TABLE ll_inventory_ledger RENAME COLUMN carton_no TO carton_mark', 'ALTER TABLE ll_inventory_ledger ADD COLUMN carton_mark VARCHAR(128) NULL AFTER sku_code'))); +PREPARE inventory_carton_020 FROM @ddl; EXECUTE inventory_carton_020; DEALLOCATE PREPARE inventory_carton_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_packages'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN carton_packages BIGINT NULL AFTER carton_mark')); +PREPARE inventory_carton_qty_020 FROM @ddl; EXECUTE inventory_carton_qty_020; DEALLOCATE PREPARE inventory_carton_qty_020; + +CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_profile_order(customer_id,order_id), UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key) +) ENGINE=InnoDB COMMENT='一件代发托盘档案'; +CREATE TABLE IF NOT EXISTS ll_dropship_carton ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', carton_sequence INT NOT NULL COMMENT '箱唛序号', + status VARCHAR(32) NOT NULL DEFAULT 'AVAILABLE' COMMENT '业务状态', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_carton(customer_id,order_id,carton_mark), KEY idx_ll_dropship_carton_status(customer_id,status), + KEY idx_ll_dropship_carton_sku(customer_id,sku_code,status) +) ENGINE=InnoDB COMMENT='一件代发箱唛明细'; +CREATE TABLE IF NOT EXISTS ll_dropship_sku_inventory ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', package_count BIGINT NOT NULL COMMENT '箱数', product_quantity BIGINT NOT NULL COMMENT '产品数量', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_sku_inventory(customer_id,order_id,sku_code) +) ENGINE=InnoDB COMMENT='一件代发SKU库存汇总'; +CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + business_order_no VARCHAR(128) NULL COMMENT '业务单号', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', remark VARCHAR(1000) NULL COMMENT '备注', occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_ledger_key(customer_id,idempotency_key), + KEY idx_ll_dropship_ledger_sku(customer_id,sku_code,carton_mark) +) ENGINE=InnoDB COMMENT='一件代发箱唛库存流水'; +-- Dropship profile stores the immutable first-leg pallet number snapshot. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='source_pallet_no'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''Pallet number snapshot supplied by first-leg handover'' AFTER pallet_no')); +PREPARE dropship_source_pallet_no_stmt FROM @ddl; EXECUTE dropship_source_pallet_no_stmt; DEALLOCATE PREPARE dropship_source_pallet_no_stmt; + +-- Explicit index upgrades for existing installations. CREATE TABLE IF NOT EXISTS +-- does not add indexes to tables that already exist. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container'), 'SELECT 1', 'ALTER TABLE ll_order ADD KEY idx_ll_order_container (customer_id,container_no)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_key'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD UNIQUE KEY uk_ll_dropship_profile_key (customer_id,idempotency_key)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND index_name='uk_ll_dropship_carton'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton ADD UNIQUE KEY uk_ll_dropship_carton (customer_id,order_id,carton_mark)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND index_name='idx_ll_dropship_carton_status'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton ADD KEY idx_ll_dropship_carton_status (customer_id,status)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND index_name='idx_ll_dropship_carton_sku'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton ADD KEY idx_ll_dropship_carton_sku (customer_id,sku_code,status)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND index_name='uk_ll_dropship_ledger_key'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton_ledger ADD UNIQUE KEY uk_ll_dropship_ledger_key (customer_id,idempotency_key)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND index_name='idx_ll_dropship_ledger_sku'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton_ledger ADD KEY idx_ll_dropship_ledger_sku (customer_id,sku_code,carton_mark)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; + +-- ============================================================================ +-- 2026-08-18 / dropship multi-pallet cardinality +-- One one-click dropship inbound order may own multiple source pallets. +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order'), 'ALTER TABLE ll_dropship_order_profile DROP INDEX uk_ll_dropship_profile_order', 'SELECT 1')); +PREPARE dropship_drop_single_pallet_stmt FROM @ddl; EXECUTE dropship_drop_single_pallet_stmt; DEALLOCATE PREPARE dropship_drop_single_pallet_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order_pallet'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)')); +PREPARE dropship_add_order_pallet_stmt FROM @ddl; EXECUTE dropship_add_order_pallet_stmt; DEALLOCATE PREPARE dropship_add_order_pallet_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='idx_ll_dropship_profile_order'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD KEY idx_ll_dropship_profile_order(customer_id,order_id)')); +PREPARE dropship_add_order_idx_stmt FROM @ddl; EXECUTE dropship_add_order_idx_stmt; DEALLOCATE PREPARE dropship_add_order_idx_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='uk_ll_dropship_sku_inventory'), 'ALTER TABLE ll_dropship_sku_inventory DROP INDEX uk_ll_dropship_sku_inventory', 'SELECT 1')); +PREPARE dropship_drop_order_sku_stmt FROM @ddl; EXECUTE dropship_drop_order_sku_stmt; DEALLOCATE PREPARE dropship_drop_order_sku_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='uk_ll_dropship_sku_profile'), 'SELECT 1', 'ALTER TABLE ll_dropship_sku_inventory ADD UNIQUE KEY uk_ll_dropship_sku_profile(profile_id,sku_code)')); +PREPARE dropship_add_profile_sku_stmt FROM @ddl; EXECUTE dropship_add_profile_sku_stmt; DEALLOCATE PREPARE dropship_add_profile_sku_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='idx_ll_dropship_sku_order'), 'SELECT 1', 'ALTER TABLE ll_dropship_sku_inventory ADD KEY idx_ll_dropship_sku_order(customer_id,order_id,sku_code)')); +PREPARE dropship_add_order_sku_idx_stmt FROM @ddl; EXECUTE dropship_add_order_sku_idx_stmt; DEALLOCATE PREPARE dropship_add_order_sku_idx_stmt; + +-- Dropship event snapshots are not physical stock until overseas PDA stock-in completes. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='warehouse_pallet_id' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN warehouse_pallet_id BIGINT NULL', 'SELECT 1')); +PREPARE dropship_profile_pallet_nullable_stmt FROM @ddl; EXECUTE dropship_profile_pallet_nullable_stmt; DEALLOCATE PREPARE dropship_profile_pallet_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='pallet_no' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN pallet_no VARCHAR(128) NULL', 'SELECT 1')); +PREPARE dropship_profile_no_nullable_stmt FROM @ddl; EXECUTE dropship_profile_no_nullable_stmt; DEALLOCATE PREPARE dropship_profile_no_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='warehouse_pallet_id' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN warehouse_pallet_id BIGINT NULL', 'SELECT 1')); +PREPARE dropship_carton_pallet_nullable_stmt FROM @ddl; EXECUTE dropship_carton_pallet_nullable_stmt; DEALLOCATE PREPARE dropship_carton_pallet_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='pallet_no' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN pallet_no VARCHAR(128) NULL', 'SELECT 1')); +PREPARE dropship_carton_no_nullable_stmt FROM @ddl; EXECUTE dropship_carton_no_nullable_stmt; DEALLOCATE PREPARE dropship_carton_no_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='status' AND column_default<>'PENDING_INBOUND'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND''', 'SELECT 1')); +PREPARE dropship_profile_pending_default_stmt FROM @ddl; EXECUTE dropship_profile_pending_default_stmt; DEALLOCATE PREPARE dropship_profile_pending_default_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='status' AND column_default<>'PENDING_INBOUND'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND''', 'SELECT 1')); +PREPARE dropship_carton_pending_default_stmt FROM @ddl; EXECUTE dropship_carton_pending_default_stmt; DEALLOCATE PREPARE dropship_carton_pending_default_stmt; + +-- ============================================================================ +-- 2026-08-18 / 021 cargo discard application, carton reservation and approval. +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', + total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', + total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', + reviewed_at DATETIME(3) 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(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no), + UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key), + KEY idx_ll_cargo_discard_list(customer_id,status,created_at), + KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃申请单'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', + available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), + KEY idx_ll_cargo_discard_item_list(customer_id,application_id), + KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code) +) ENGINE=InnoDB COMMENT='货物废弃SKU明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), + KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), + KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id) +) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃审批历史'; + +-- ============================================================================ +-- 2026-08-18 / 022 overseas procurement approval, inventory and outbound flow. +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + order_no VARCHAR(64) NOT NULL COMMENT '订单号', purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称', + product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接', purchase_quantity BIGINT NOT NULL COMMENT '采购数量', + budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', + purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计入库数量', outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计出库数量', + domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', + overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', + remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + 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 '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key), + KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购订单'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', quantity BIGINT NOT NULL COMMENT '数量', + outbound_no VARCHAR(128) NOT NULL COMMENT '出库单号', carrier VARCHAR(128) NULL COMMENT '承运商', tracking_no VARCHAR(128) NULL COMMENT '物流跟踪号', outbound_at DATETIME(3) NOT NULL COMMENT '出库时间', + remark VARCHAR(1000) NULL COMMENT '备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_overseas_procurement_outbound_no(customer_id,order_id,outbound_no), KEY idx_ll_overseas_procurement_outbound_order(customer_id,order_id,outbound_at) +) ENGINE=InnoDB COMMENT='海外采购出库记录'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购审核历史'; + +-- 2026-08-18 / 023 US domestic trucking customer and platform workflow. +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号', + transport_type VARCHAR(32) NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址', container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', + pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', package_count INT NULL COMMENT '可选箱数,零或空值均有效', + received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', + allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数', shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已出库托盘数', + total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', + total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', budget_amount DECIMAL(18,2) NULL COMMENT '预算金额', + currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式', requested_date DATE NOT NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态', + remark VARCHAR(1000) NULL COMMENT '备注', receipt_idempotency_key VARCHAR(255) NULL COMMENT '实物收货幂等键', + receipt_request_hash CHAR(64) NULL COMMENT '实物收货请求哈希', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', 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) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no), + UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key), + KEY idx_ll_us_truck_order_list(customer_id,status,created_at), + KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='美国卡车运输订单'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), KEY idx_ll_us_truck_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; + +-- ============================================================================ +-- 2026-08-18 / 024 warehouse transfer inventory allocation, outbound and receipt. +-- Creation and approval remain document-only. Inventory moves on outbound and receipt. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键'' AFTER status')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_request_hash CHAR(64) NULL COMMENT ''库存分配请求哈希'' AFTER allocation_idempotency_key')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_idempotency_key VARCHAR(255) NULL COMMENT ''出库幂等键'' AFTER allocation_request_hash')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_request_hash CHAR(64) NULL COMMENT ''出库请求哈希'' AFTER outbound_idempotency_key')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( + id BIGINT NOT NULL COMMENT '雪花主键', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', inventory_balance_id BIGINT NOT NULL COMMENT '来源库存余额记录ID', + inbound_order_id BIGINT NOT NULL COMMENT '来源入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '来源入仓单号快照', + pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', package_count INT NOT NULL COMMENT '分配箱数', + status VARCHAR(32) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', active_position_key VARCHAR(255) NULL COMMENT '生效中的仓位排他键', + allocated_at DATETIME(3) NOT NULL COMMENT '分配时间', shipped_at DATETIME(3) NULL COMMENT '出库时间', received_at DATETIME(3) 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(id) +) ENGINE=InnoDB COMMENT='转仓单来源库存分配记录'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_receipt ( + id BIGINT NOT NULL COMMENT '雪花主键', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', allocation_id BIGINT NOT NULL COMMENT '库存分配记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', source_inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', + destination_inventory_balance_id BIGINT NOT NULL COMMENT '目标库存余额记录ID', destination_inventory_position_id BIGINT NOT NULL COMMENT '目标库存仓位记录ID', + package_count INT NOT NULL COMMENT '收货箱数', idempotency_key VARCHAR(255) NOT NULL COMMENT '收货幂等键', request_hash CHAR(64) NOT NULL COMMENT '收货请求哈希', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + received_at DATETIME(3) NOT NULL COMMENT '收货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id) +) ENGINE=InnoDB COMMENT='转仓来源仓位的目标库存收货记录'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND index_name='uk_ll_wh_transfer_allocation_position'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_inventory_allocation ADD UNIQUE KEY uk_ll_wh_transfer_allocation_position(transfer_order_id,inventory_position_id)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND index_name='uk_ll_wh_transfer_active_position'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_inventory_allocation ADD UNIQUE KEY uk_ll_wh_transfer_active_position(customer_id,active_position_key)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND index_name='idx_ll_wh_transfer_allocation_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_inventory_allocation ADD KEY idx_ll_wh_transfer_allocation_list(customer_id,transfer_order_id,status)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='uk_ll_wh_transfer_receipt_source'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD UNIQUE KEY uk_ll_wh_transfer_receipt_source(transfer_order_id,source_inventory_position_id)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='uk_ll_wh_transfer_receipt_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD UNIQUE KEY uk_ll_wh_transfer_receipt_key(customer_id,idempotency_key)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='uk_ll_wh_transfer_receipt_operation'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD UNIQUE KEY uk_ll_wh_transfer_receipt_operation(customer_id,inventory_operation_key)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='idx_ll_wh_transfer_receipt_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD KEY idx_ll_wh_transfer_receipt_list(customer_id,transfer_order_id,received_at)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; + +-- 2026-08-18 / 024 Pallet-first US domestic trucking receipt workflow. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='transport_type' AND is_nullable='NO'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN transport_type VARCHAR(32) NULL COMMENT ''运输类型''', 'SELECT 1')); +PREPARE us_truck_transport_type_stmt FROM @ddl; EXECUTE us_truck_transport_type_stmt; DEALLOCATE PREPARE us_truck_transport_type_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='container_type' AND is_nullable='NO'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type VARCHAR(32) NULL COMMENT ''历史兼容设备类型,零担托盘业务不采集''', 'SELECT 1')); +PREPARE us_truck_container_type_stmt FROM @ddl; EXECUTE us_truck_container_type_stmt; DEALLOCATE PREPARE us_truck_container_type_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN pallet_count INT NOT NULL DEFAULT 1 COMMENT ''客户申报托盘数'' AFTER container_type')); +PREPARE us_truck_pallet_count_stmt FROM @ddl; EXECUTE us_truck_pallet_count_stmt; DEALLOCATE PREPARE us_truck_pallet_count_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='received_pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN received_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''PDA实收托盘数'' AFTER package_count')); +PREPARE us_truck_received_stmt FROM @ddl; EXECUTE us_truck_received_stmt; DEALLOCATE PREPARE us_truck_received_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='allocated_pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''已分配到合车行程的托盘数'' AFTER received_pallet_count')); +PREPARE us_truck_allocated_stmt FROM @ddl; EXECUTE us_truck_allocated_stmt; DEALLOCATE PREPARE us_truck_allocated_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='shipped_pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''已出库托盘数'' AFTER allocated_pallet_count')); +PREPARE us_truck_shipped_stmt FROM @ddl; EXECUTE us_truck_shipped_stmt; DEALLOCATE PREPARE us_truck_shipped_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='receipt_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN receipt_idempotency_key VARCHAR(255) NULL COMMENT ''实物收货幂等键'' AFTER remark')); +PREPARE us_truck_receipt_key_stmt FROM @ddl; EXECUTE us_truck_receipt_key_stmt; DEALLOCATE PREPARE us_truck_receipt_key_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='receipt_request_hash'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN receipt_request_hash CHAR(64) NULL COMMENT ''实物收货请求哈希'' AFTER receipt_idempotency_key')); +PREPARE us_truck_receipt_hash_stmt FROM @ddl; EXECUTE us_truck_receipt_hash_stmt; DEALLOCATE PREPARE us_truck_receipt_hash_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND index_name='uk_ll_us_truck_receipt_key'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD UNIQUE KEY uk_ll_us_truck_receipt_key(customer_id,receipt_idempotency_key)')); +PREPARE us_truck_receipt_index_stmt FROM @ddl; EXECUTE us_truck_receipt_index_stmt; DEALLOCATE PREPARE us_truck_receipt_index_stmt; +CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( + id BIGINT NOT NULL COMMENT '托盘记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '业务托盘号', sequence_no INT NOT NULL COMMENT '订单内托盘序号', source VARCHAR(32) NOT NULL COMMENT '托盘来源:订单计划或PDA超收', + status VARCHAR(32) NOT NULL COMMENT '托盘状态:待收、已收或超收待审核', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘资源ID', package_count INT NULL COMMENT '托盘箱数', + received_at DATETIME(3) 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(id), UNIQUE KEY uk_ll_us_truck_order_pallet_no(customer_id,order_id,pallet_no), + UNIQUE KEY uk_ll_us_truck_order_pallet_seq(customer_id,order_id,sequence_no), + KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status) +) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录';+ +-- ============================================================================ +CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( + id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', + line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', + warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), + KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), + KEY idx_ll_inbound_carton_order(order_id,status) +) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账'; + +-- 2026-08-18 / 025 Chinese table and column comments for Lastleg and Warehouse. +-- Idempotently updates metadata for databases where CREATE TABLE already ran. +-- ============================================================================ + +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND COALESCE(table_comment,'') <> '头程装柜完成事件接收幂等箱'), 'ALTER TABLE ll_loading_event_inbox COMMENT = ''头程装柜完成事件接收幂等箱''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='event_id' AND COALESCE(column_comment,'') <> '事件全局唯一标识'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT ''事件全局唯一标识''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户/客户账号标识'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''租户/客户账号标识''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='event_type' AND COALESCE(column_comment,'') <> '事件类型'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT ''事件类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='event_version' AND COALESCE(column_comment,'') <> '事件契约版本'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN event_version INT NOT NULL COMMENT ''事件契约版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='payload_hash' AND COALESCE(column_comment,'') <> '规范化事件负载 SHA-256 摘要'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT ''规范化事件负载 SHA-256 摘要''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='payload_json' AND COALESCE(column_comment,'') <> '事件冻结负载 JSON'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN payload_json JSON NOT NULL COMMENT ''事件冻结负载 JSON''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='status' AND COALESCE(column_comment,'') <> '消费状态:RECEIVED/PROCESSED'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''RECEIVED'' COMMENT ''消费状态:RECEIVED/PROCESSED''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='received_at' AND COALESCE(column_comment,'') <> '接收时间'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''接收时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='processed_at' AND COALESCE(column_comment,'') <> '处理完成时间'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN processed_at DATETIME(3) NULL COMMENT ''处理完成时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='last_error' AND COALESCE(column_comment,'') <> '最近一次处理错误'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN last_error VARCHAR(1000) NULL COMMENT ''最近一次处理错误''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_order' AND COALESCE(table_comment,'') <> '尾程订单主表'), 'ALTER TABLE ll_order COMMENT = ''尾程订单主表''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='id' AND COALESCE(column_comment,'') <> '雪花 ID'), 'ALTER TABLE ll_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''雪花 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='order_no' AND COALESCE(column_comment,'') <> '尾程单号'), 'ALTER TABLE ll_order MODIFY COLUMN order_no VARCHAR(32) NOT NULL COMMENT ''尾程单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户/客户账号标识'), 'ALTER TABLE ll_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''租户/客户账号标识''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='order_type' AND COALESCE(column_comment,'') <> '订单类型'), 'ALTER TABLE ll_order MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT ''FIRST_LEG'' COMMENT ''订单类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓码'), 'ALTER TABLE ll_order MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_region' AND COALESCE(column_comment,'') <> '来源区域/系统'), 'ALTER TABLE ll_order MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT ''来源区域/系统''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_inbound_id' AND COALESCE(column_comment,'') <> '最终实装 LOADED 分片 ID,建单幂等键'), 'ALTER TABLE ll_order MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT ''最终实装 LOADED 分片 ID,建单幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='root_inbound_id' AND COALESCE(column_comment,'') <> '根入仓单 ID'), 'ALTER TABLE ll_order MODIFY COLUMN root_inbound_id VARCHAR(128) COMMENT ''根入仓单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_order_id' AND COALESCE(column_comment,'') <> '来源订单 ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_order_id VARCHAR(128) COMMENT ''来源订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_order_no' AND COALESCE(column_comment,'') <> '来源订单号'), 'ALTER TABLE ll_order MODIFY COLUMN source_order_no VARCHAR(128) COMMENT ''来源订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='root_order_id' AND COALESCE(column_comment,'') <> '根订单 ID'), 'ALTER TABLE ll_order MODIFY COLUMN root_order_id VARCHAR(128) COMMENT ''根订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='customer_name' AND COALESCE(column_comment,'') <> '货主名称'), 'ALTER TABLE ll_order MODIFY COLUMN customer_name VARCHAR(255) COMMENT ''货主名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓 ID'), 'ALTER TABLE ll_order MODIFY COLUMN overseas_warehouse_id VARCHAR(128) COMMENT ''海外仓 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称'), 'ALTER TABLE ll_order MODIFY COLUMN overseas_warehouse_name VARCHAR(255) COMMENT ''海外仓名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_id' AND COALESCE(column_comment,'') <> '转入目的仓 ID'), 'ALTER TABLE ll_order MODIFY COLUMN transfer_warehouse_id VARCHAR(128) COMMENT ''转入目的仓 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_code' AND COALESCE(column_comment,'') <> '转入目的仓编码'), 'ALTER TABLE ll_order MODIFY COLUMN transfer_warehouse_code VARCHAR(128) COMMENT ''转入目的仓编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_name' AND COALESCE(column_comment,'') <> '转入目的仓名称'), 'ALTER TABLE ll_order MODIFY COLUMN transfer_warehouse_name VARCHAR(255) COMMENT ''转入目的仓名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA 仓库编码'), 'ALTER TABLE ll_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) COMMENT ''FBA 仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='container_no' AND COALESCE(column_comment,'') <> '集装箱号'), 'ALTER TABLE ll_order MODIFY COLUMN container_no VARCHAR(64) COMMENT ''集装箱号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='container_status' AND COALESCE(column_comment,'') <> '货柜状态'), 'ALTER TABLE ll_order MODIFY COLUMN container_status VARCHAR(32) DEFAULT ''待转柜'' COMMENT ''货柜状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='abnormal' AND COALESCE(column_comment,'') <> '是否异常:0否1是'), 'ALTER TABLE ll_order MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT ''是否异常:0否1是''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='detained' AND COALESCE(column_comment,'') <> '是否暂扣:0否1是'), 'ALTER TABLE ll_order MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT ''是否暂扣:0否1是''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_order MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT ''托数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_order MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count' AND COALESCE(column_comment,'') <> '累计已拣箱数'), 'ALTER TABLE ll_order MODIFY COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count' AND COALESCE(column_comment,'') <> '累计已出库箱数'), 'ALTER TABLE ll_order MODIFY COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_order MODIFY COLUMN volume_cbm DECIMAL(18,4) COMMENT ''体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '货物数量'), 'ALTER TABLE ll_order MODIFY COLUMN product_quantity INT COMMENT ''货物数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='volume_weight_kg' AND COALESCE(column_comment,'') <> '体积重量(千克)'), 'ALTER TABLE ll_order MODIFY COLUMN volume_weight_kg DECIMAL(18,4) COMMENT ''体积重量(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到港时间'), 'ALTER TABLE ll_order MODIFY COLUMN estimated_arrival_at DATETIME(3) COMMENT ''预计到港时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='actual_arrival_at' AND COALESCE(column_comment,'') <> '实际到港时间'), 'ALTER TABLE ll_order MODIFY COLUMN actual_arrival_at DATETIME(3) COMMENT ''实际到港时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='estimated_warehouse_at' AND COALESCE(column_comment,'') <> '预计到仓时间'), 'ALTER TABLE ll_order MODIFY COLUMN estimated_warehouse_at DATETIME(3) COMMENT ''预计到仓时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='inbound_order_no' AND COALESCE(column_comment,'') <> '入库单号'), 'ALTER TABLE ll_order MODIFY COLUMN inbound_order_no VARCHAR(128) COMMENT ''入库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='outbound_order_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_order MODIFY COLUMN outbound_order_no VARCHAR(128) COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='delivery_order_no' AND COALESCE(column_comment,'') <> '派送单号'), 'ALTER TABLE ll_order MODIFY COLUMN delivery_order_no VARCHAR(128) COMMENT ''派送单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='relabel_order_no' AND COALESCE(column_comment,'') <> '换标单号'), 'ALTER TABLE ll_order MODIFY COLUMN relabel_order_no VARCHAR(128) COMMENT ''换标单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='relabel_status' AND COALESCE(column_comment,'') <> '换标状态'), 'ALTER TABLE ll_order MODIFY COLUMN relabel_status VARCHAR(32) COMMENT ''换标状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_flag' AND COALESCE(column_comment,'') <> '合托标记'), 'ALTER TABLE ll_order MODIFY COLUMN pallet_merge_flag VARCHAR(32) COMMENT ''合托标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='current_status' AND COALESCE(column_comment,'') <> '当前业务状态'), 'ALTER TABLE ll_order MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT ''PENDING_ASSIGNMENT'' COMMENT ''当前业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='private_remark' AND COALESCE(column_comment,'') <> '备注(仅自己)'), 'ALTER TABLE ll_order MODIFY COLUMN private_remark VARCHAR(1000) COMMENT ''备注(仅自己)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='customer_remark' AND COALESCE(column_comment,'') <> '备注(客户)'), 'ALTER TABLE ll_order MODIFY COLUMN customer_remark VARCHAR(1000) COMMENT ''备注(客户)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='warehouse_remark' AND COALESCE(column_comment,'') <> '备注(仓库)'), 'ALTER TABLE ll_order MODIFY COLUMN warehouse_remark VARCHAR(1000) COMMENT ''备注(仓库)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_event_id' AND COALESCE(column_comment,'') <> '来源事件 ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT ''来源事件 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_loading_plan_id' AND COALESCE(column_comment,'') <> '来源装柜计划 ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT ''来源装柜计划 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id' AND COALESCE(column_comment,'') <> '来源头程货柜预订ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND COALESCE(table_comment,'') <> '柜次冻结箱唛及海外入库箱级作业账'), 'ALTER TABLE ll_inbound_plan_carton COMMENT = ''柜次冻结箱唛及海外入库箱级作业账''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='id' AND COALESCE(column_comment,'') <> '箱唛作业主键'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN id BIGINT NOT NULL COMMENT ''箱唛作业主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='order_id' AND COALESCE(column_comment,'') <> '海外首扫确权后的尾程订单ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN order_id BIGINT NULL COMMENT ''海外首扫确权后的尾程订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='source_inbound_detail_id' AND COALESCE(column_comment,'') <> '来源入库明细ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT ''来源入库明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT ''SKU编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '国内冻结箱唛'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN carton_mark VARCHAR(128) NOT NULL COMMENT ''国内冻结箱唛''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '海外入库计划ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN plan_id BIGINT NULL COMMENT ''海外入库计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入库计划明细ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN line_id BIGINT NULL COMMENT ''入库计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='plan_pallet_id' AND COALESCE(column_comment,'') <> '海外计划托盘ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN plan_pallet_id BIGINT NULL COMMENT ''海外计划托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '海外实物托盘ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''海外实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='warehouse_location_id' AND COALESCE(column_comment,'') <> '海外库位ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''海外库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='pda_task_id' AND COALESCE(column_comment,'') <> '首次扫码PDA任务ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN pda_task_id BIGINT NULL COMMENT ''首次扫码PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='status' AND COALESCE(column_comment,'') <> '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='bound_at' AND COALESCE(column_comment,'') <> '绑定托盘时间'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN bound_at DATETIME(3) NULL COMMENT ''绑定托盘时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='received_at' AND COALESCE(column_comment,'') <> '完成入库时间'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN received_at DATETIME(3) NULL COMMENT ''完成入库时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_loading_plan_version' AND COALESCE(column_comment,'') <> '来源装柜计划版本'), 'ALTER TABLE ll_order MODIFY COLUMN source_loading_plan_version BIGINT COMMENT ''来源装柜计划版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_handover_version' AND COALESCE(column_comment,'') <> '交接版本'), 'ALTER TABLE ll_order MODIFY COLUMN source_handover_version BIGINT COMMENT ''交接版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_payload_hash' AND COALESCE(column_comment,'') <> '来源事件负载摘要'), 'ALTER TABLE ll_order MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT ''来源事件负载摘要''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND COALESCE(table_comment,'') <> '尾程订单实装商品冻结明细'), 'ALTER TABLE ll_order_detail COMMENT = ''尾程订单实装商品冻结明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='id' AND COALESCE(column_comment,'') <> '订单明细主键'), 'ALTER TABLE ll_order_detail MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''订单明细主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='order_id' AND COALESCE(column_comment,'') <> '尾程订单 ID'), 'ALTER TABLE ll_order_detail MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''尾程订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='source_inbound_detail_id' AND COALESCE(column_comment,'') <> '来源入仓明细 ID'), 'ALTER TABLE ll_order_detail MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT ''来源入仓明细 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU 编码'), 'ALTER TABLE ll_order_detail MODIFY COLUMN sku_code VARCHAR(128) COMMENT ''SKU 编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '中文品名'), 'ALTER TABLE ll_order_detail MODIFY COLUMN product_name_zh VARCHAR(255) COMMENT ''中文品名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='product_name_en' AND COALESCE(column_comment,'') <> '英文品名'), 'ALTER TABLE ll_order_detail MODIFY COLUMN product_name_en VARCHAR(255) COMMENT ''英文品名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_order_detail MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_order_detail MODIFY COLUMN product_quantity INT COMMENT ''数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_order_detail MODIFY COLUMN pallet_count INT COMMENT ''托数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='gross_weight_kg' AND COALESCE(column_comment,'') <> '毛重(千克)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN gross_weight_kg DECIMAL(18,4) COMMENT ''毛重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='net_weight_kg' AND COALESCE(column_comment,'') <> '净重(千克)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN net_weight_kg DECIMAL(18,4) COMMENT ''净重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='dimensional_weight_kg' AND COALESCE(column_comment,'') <> '体积重量(千克)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN dimensional_weight_kg DECIMAL(18,4) COMMENT ''体积重量(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN volume_cbm DECIMAL(18,4) COMMENT ''体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓 ID'), 'ALTER TABLE ll_order_detail MODIFY COLUMN destination_warehouse_id VARCHAR(128) COMMENT ''目的仓 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码'), 'ALTER TABLE ll_order_detail MODIFY COLUMN destination_warehouse_code VARCHAR(128) COMMENT ''目的仓编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称'), 'ALTER TABLE ll_order_detail MODIFY COLUMN destination_warehouse_name VARCHAR(255) COMMENT ''目的仓名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='fba_tracking_no' AND COALESCE(column_comment,'') <> 'FBA跟踪号'), 'ALTER TABLE ll_order_detail MODIFY COLUMN fba_tracking_no VARCHAR(255) COMMENT ''FBA跟踪号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='shipment_no' AND COALESCE(column_comment,'') <> '货件编号'), 'ALTER TABLE ll_order_detail MODIFY COLUMN shipment_no VARCHAR(255) COMMENT ''货件编号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='shipping_mark' AND COALESCE(column_comment,'') <> '唛头'), 'ALTER TABLE ll_order_detail MODIFY COLUMN shipping_mark VARCHAR(255) COMMENT ''唛头''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND COALESCE(table_comment,'') <> '尾程订单状态变更历史'), 'ALTER TABLE ll_loading_order_status_history COMMENT = ''尾程订单状态变更历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '状态历史主键'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''状态历史主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '尾程订单 ID'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''尾程订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN from_status VARCHAR(32) COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='event_id' AND COALESCE(column_comment,'') <> '触发事件 ID'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN event_id VARCHAR(128) COMMENT ''触发事件 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '状态变更备注'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN remark VARCHAR(500) COMMENT ''状态变更备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '记录时间'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''记录时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND COALESCE(table_comment,'') <> '事件冲突记录'), 'ALTER TABLE ll_event_conflict COMMENT = ''事件冲突记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='id' AND COALESCE(column_comment,'') <> '冲突记录主键'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN id BIGINT NOT NULL COMMENT ''冲突记录主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='event_id' AND COALESCE(column_comment,'') <> '冲突事件 ID'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT ''冲突事件 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''租户 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='conflict_type' AND COALESCE(column_comment,'') <> '冲突类型'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN conflict_type VARCHAR(64) NOT NULL COMMENT ''冲突类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='conflict_message' AND COALESCE(column_comment,'') <> '冲突说明'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN conflict_message VARCHAR(512) NOT NULL COMMENT ''冲突说明''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND COALESCE(table_comment,'') <> '通用事件接收幂等箱'), 'ALTER TABLE ll_event_inbox COMMENT = ''通用事件接收幂等箱''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='id' AND COALESCE(column_comment,'') <> '事件接收记录主键'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN id BIGINT NOT NULL COMMENT ''事件接收记录主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='event_id' AND COALESCE(column_comment,'') <> '事件全局唯一 ID'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT ''事件全局唯一 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''租户 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='event_type' AND COALESCE(column_comment,'') <> '事件类型'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN event_type VARCHAR(128) NOT NULL COMMENT ''事件类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='aggregate_type' AND COALESCE(column_comment,'') <> '聚合类型'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN aggregate_type VARCHAR(64) NOT NULL COMMENT ''聚合类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='aggregate_id' AND COALESCE(column_comment,'') <> '聚合 ID'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN aggregate_id VARCHAR(128) NOT NULL COMMENT ''聚合 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='aggregate_version' AND COALESCE(column_comment,'') <> '聚合版本'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN aggregate_version BIGINT NOT NULL COMMENT ''聚合版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='payload_hash' AND COALESCE(column_comment,'') <> '负载摘要'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT ''负载摘要''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='status' AND COALESCE(column_comment,'') <> '消费状态'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''消费状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='error_message' AND COALESCE(column_comment,'') <> '错误信息'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN error_message VARCHAR(512) NULL COMMENT ''错误信息''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND COALESCE(table_comment,'') <> '尾程入仓单快照'), 'ALTER TABLE ll_inbound_order COMMENT = ''尾程入仓单快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='id' AND COALESCE(column_comment,'') <> '入仓单主键'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''入仓单主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''租户 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_region' AND COALESCE(column_comment,'') <> '来源区域'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT ''来源区域''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_inbound_id' AND COALESCE(column_comment,'') <> '来源入仓分片 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT ''来源入仓分片 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='root_inbound_id' AND COALESCE(column_comment,'') <> '根入仓单 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT ''根入仓单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_order_id' AND COALESCE(column_comment,'') <> '来源订单 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT ''来源订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_order_no' AND COALESCE(column_comment,'') <> '来源订单号'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT ''来源订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='root_order_id' AND COALESCE(column_comment,'') <> '根订单 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT ''根订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓码'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN product_quantity INT NULL COMMENT ''数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN pallet_count INT NULL COMMENT ''托数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='gross_weight_kg' AND COALESCE(column_comment,'') <> '毛重(千克)'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT ''毛重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='net_weight_kg' AND COALESCE(column_comment,'') <> '净重(千克)'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT ''净重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT ''体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_event_id' AND COALESCE(column_comment,'') <> '来源事件 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT ''来源事件 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_loading_plan_id' AND COALESCE(column_comment,'') <> '装柜计划 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT ''装柜计划 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_loading_plan_version' AND COALESCE(column_comment,'') <> '装柜计划版本'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_loading_plan_version BIGINT NOT NULL COMMENT ''装柜计划版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='handover_version' AND COALESCE(column_comment,'') <> '交接版本'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN handover_version BIGINT NOT NULL COMMENT ''交接版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='payload_hash' AND COALESCE(column_comment,'') <> '负载摘要'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT ''负载摘要''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='status' AND COALESCE(column_comment,'') <> '入仓单状态'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''入仓单状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='exception_flag' AND COALESCE(column_comment,'') <> '异常标记'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN exception_flag VARCHAR(32) NOT NULL DEFAULT ''NONE'' COMMENT ''异常标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND COALESCE(table_comment,'') <> '尾程入仓商品明细快照'), 'ALTER TABLE ll_inbound_order_item COMMENT = ''尾程入仓商品明细快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='id' AND COALESCE(column_comment,'') <> '入仓明细主键'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''入仓明细主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''租户 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '入仓单 ID'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''入仓单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='source_inbound_detail_id' AND COALESCE(column_comment,'') <> '来源入仓明细 ID'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT ''来源入仓明细 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN package_count INT NOT NULL COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN product_quantity INT NULL COMMENT ''数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN pallet_count INT NULL COMMENT ''托数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='gross_weight_kg' AND COALESCE(column_comment,'') <> '毛重(千克)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT ''毛重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='net_weight_kg' AND COALESCE(column_comment,'') <> '净重(千克)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT ''净重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='dimensional_weight_kg' AND COALESCE(column_comment,'') <> '体积重量(千克)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN dimensional_weight_kg DECIMAL(18,3) NULL COMMENT ''体积重量(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT ''体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓 ID'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='fba_tracking_no' AND COALESCE(column_comment,'') <> 'FBA跟踪号'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN fba_tracking_no VARCHAR(128) NULL COMMENT ''FBA跟踪号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='shipment_no' AND COALESCE(column_comment,'') <> '货件编号'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='shipping_mark' AND COALESCE(column_comment,'') <> '唛头'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT ''唛头''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU 编码'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT ''SKU 编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '中文品名'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='product_name_en' AND COALESCE(column_comment,'') <> '英文品名'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND COALESCE(table_comment,'') <> '尾程订单状态历史'), 'ALTER TABLE ll_order_status_history COMMENT = ''尾程订单状态历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '订单状态历史主键'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN id BIGINT NOT NULL COMMENT ''订单状态历史主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''租户 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单 ID'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='reason' AND COALESCE(column_comment,'') <> '变更原因'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN reason VARCHAR(255) NULL COMMENT ''变更原因''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '记录时间'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''记录时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND COALESCE(table_comment,'') <> '尾程入库计划表:每个租户的每个集装箱生成一张入库单'), 'ALTER TABLE ll_inbound_plan COMMENT = ''尾程入库计划表:每个租户的每个集装箱生成一张入库单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='id' AND COALESCE(column_comment,'') <> '入库计划ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN id BIGINT NOT NULL COMMENT ''入库计划ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_no' AND COALESCE(column_comment,'') <> '入库单号'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT ''入库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户/客户账号标识'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''租户/客户账号标识''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='container_no' AND COALESCE(column_comment,'') <> '集装箱号'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN container_no VARCHAR(64) NOT NULL COMMENT ''集装箱号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='status' AND COALESCE(column_comment,'') <> '入库计划状态'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PLANNED'' COMMENT ''入库计划状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='line_count' AND COALESCE(column_comment,'') <> '入仓码明细数量'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN line_count INT NOT NULL DEFAULT 0 COMMENT ''入仓码明细数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '系统自动分配托盘数量'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT ''系统自动分配托盘数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='total_volume_cbm' AND COALESCE(column_comment,'') <> '集装箱总体积(立方米)'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''集装箱总体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND COALESCE(table_comment,'') <> '尾程入库计划明细表:按入仓码和尾程订单维度记录'), 'ALTER TABLE ll_inbound_plan_line COMMENT = ''尾程入库计划明细表:按入仓码和尾程订单维度记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='id' AND COALESCE(column_comment,'') <> '入库计划明细ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN id BIGINT NOT NULL COMMENT ''入库计划明细ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入库计划ID'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入库计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='order_id' AND COALESCE(column_comment,'') <> '尾程订单ID'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''尾程订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '头程入仓码快照'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''头程入仓码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID快照'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '入仓码体积(立方米)'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''入仓码体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='planned_pallet_count' AND COALESCE(column_comment,'') <> '该明细分配的托盘数量'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''该明细分配的托盘数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND COALESCE(table_comment,'') <> '尾程入库计划托盘表:按目的仓隔离合托'), 'ALTER TABLE ll_inbound_plan_pallet COMMENT = ''尾程入库计划托盘表:按目的仓隔离合托''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='id' AND COALESCE(column_comment,'') <> '计划托盘ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN id BIGINT NOT NULL COMMENT ''计划托盘ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入库计划ID'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入库计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '计划托盘编号'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT ''计划托盘编号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID快照'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='capacity_cbm' AND COALESCE(column_comment,'') <> '托盘容量,固定为2立方米'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT ''托盘容量,固定为2立方米''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='allocated_volume_cbm' AND COALESCE(column_comment,'') <> '已分配体积(立方米)'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''已分配体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='sequence_no' AND COALESCE(column_comment,'') <> '计划内托盘序号'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN sequence_no INT NOT NULL COMMENT ''计划内托盘序号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND COALESCE(table_comment,'') <> '尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'), 'ALTER TABLE ll_inbound_plan_pallet_allocation COMMENT = ''尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘分配记录ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''托盘分配记录ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入库计划ID'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入库计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入库计划明细ID'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN line_id BIGINT NOT NULL COMMENT ''入库计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '计划托盘ID'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''计划托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='allocated_volume_cbm' AND COALESCE(column_comment,'') <> '该明细分配到托盘的体积(立方米)'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT ''该明细分配到托盘的体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND COALESCE(table_comment,'') <> '入仓PDA作业任务'), 'ALTER TABLE ll_inbound_pda_task COMMENT = ''入仓PDA作业任务''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入仓计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='task_no' AND COALESCE(column_comment,'') <> 'PDA任务号'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN task_no VARCHAR(64) NOT NULL COMMENT ''PDA任务号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='operator_name' AND COALESCE(column_comment,'') <> '操作人姓名'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN operator_name VARCHAR(255) NULL COMMENT ''操作人姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='pushed_at' AND COALESCE(column_comment,'') <> '任务下发时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN pushed_at DATETIME(3) NULL COMMENT ''任务下发时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='started_at' AND COALESCE(column_comment,'') <> '开始时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN started_at DATETIME(3) NULL COMMENT ''开始时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='completed_at' AND COALESCE(column_comment,'') <> '完成时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''完成时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND COALESCE(table_comment,'') <> '入仓PDA操作幂等记录'), 'ALTER TABLE ll_inbound_pda_operation COMMENT = ''入仓PDA操作幂等记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN task_id BIGINT NOT NULL COMMENT ''PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='allocation_id' AND COALESCE(column_comment,'') <> '分配记录ID'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN allocation_id BIGINT NULL COMMENT ''分配记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='action' AND COALESCE(column_comment,'') <> '操作动作'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN action VARCHAR(32) NOT NULL COMMENT ''操作动作''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN request_hash VARCHAR(128) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='request_json' AND COALESCE(column_comment,'') <> '请求内容JSON'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN request_json LONGTEXT NOT NULL COMMENT ''请求内容JSON''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='result_json' AND COALESCE(column_comment,'') <> '处理结果JSON'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN result_json LONGTEXT NOT NULL COMMENT ''处理结果JSON''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND COALESCE(table_comment,'') <> '入仓托盘合托记录'), 'ALTER TABLE ll_inbound_pallet_merge COMMENT = ''入仓托盘合托记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN task_id BIGINT NOT NULL COMMENT ''PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='merge_group_no' AND COALESCE(column_comment,'') <> '合托分组号'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN merge_group_no VARCHAR(64) NOT NULL COMMENT ''合托分组号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='source_pallet_id' AND COALESCE(column_comment,'') <> '来源托盘ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_pallet_id BIGINT NOT NULL COMMENT ''来源托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='target_pallet_id' AND COALESCE(column_comment,'') <> '目标托盘ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_pallet_id BIGINT NOT NULL COMMENT ''目标托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='source_allocation_id' AND COALESCE(column_comment,'') <> '来源分配记录ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_allocation_id BIGINT NOT NULL COMMENT ''来源分配记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='target_allocation_id' AND COALESCE(column_comment,'') <> '目标分配记录ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_allocation_id BIGINT NOT NULL COMMENT ''目标分配记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='source_line_id' AND COALESCE(column_comment,'') <> '来源计划明细ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_line_id BIGINT NOT NULL COMMENT ''来源计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='target_line_id' AND COALESCE(column_comment,'') <> '目标计划明细ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_line_id BIGINT NOT NULL COMMENT ''目标计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='source_order_id' AND COALESCE(column_comment,'') <> '来源订单ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_order_id BIGINT NOT NULL COMMENT ''来源订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='target_order_id' AND COALESCE(column_comment,'') <> '目标订单ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_order_id BIGINT NOT NULL COMMENT ''目标订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='moved_package_count' AND COALESCE(column_comment,'') <> '移动箱数'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN moved_package_count INT NOT NULL COMMENT ''移动箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='moved_volume_cbm' AND COALESCE(column_comment,'') <> '移动体积(立方米)'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''移动体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='source_pallet_scrapped' AND COALESCE(column_comment,'') <> '来源托盘是否已报废'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_pallet_scrapped TINYINT NOT NULL DEFAULT 0 COMMENT ''来源托盘是否已报废''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND COALESCE(table_comment,'') <> 'FBA入仓预约单'), 'ALTER TABLE ll_inbound_fba_appointment COMMENT = ''FBA入仓预约单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='appointment_no' AND COALESCE(column_comment,'') <> '预约单号'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_no VARCHAR(64) NOT NULL COMMENT ''预约单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='appointment_at' AND COALESCE(column_comment,'') <> '预约时间'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_at DATETIME(3) NOT NULL COMMENT ''预约时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='appointment_status' AND COALESCE(column_comment,'') <> '预约状态'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''预约状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='appointment_remark' AND COALESCE(column_comment,'') <> '预约备注'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_remark VARCHAR(1000) NULL COMMENT ''预约备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN fba_warehouse_code VARCHAR(128) NOT NULL COMMENT ''FBA仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='selected_count' AND COALESCE(column_comment,'') <> '已选明细数量'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN selected_count INT NOT NULL DEFAULT 0 COMMENT ''已选明细数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND COALESCE(table_comment,'') <> 'FBA入仓预约明细'), 'ALTER TABLE ll_inbound_fba_appointment_item COMMENT = ''FBA入仓预约明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='appointment_id' AND COALESCE(column_comment,'') <> '预约单ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN appointment_id BIGINT NOT NULL COMMENT ''预约单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入仓计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入仓计划明细ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN line_id BIGINT NOT NULL COMMENT ''入仓计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN fba_warehouse_code VARCHAR(128) NOT NULL COMMENT ''FBA仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND COALESCE(table_comment,'') <> '入仓单维度实时库存余额'), 'ALTER TABLE ll_inventory_balance COMMENT = ''入仓单维度实时库存余额''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_name' AND COALESCE(column_comment,'') <> '客户名称快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='overseas_warehouse_code' AND COALESCE(column_comment,'') <> '海外仓编码快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL COMMENT ''入仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_no' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_no VARCHAR(128) NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='available_packages' AND COALESCE(column_comment,'') <> '可用箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN available_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''可用箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='hold_packages' AND COALESCE(column_comment,'') <> '冻结箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''冻结箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='allocated_packages' AND COALESCE(column_comment,'') <> '已分配箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''已分配箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='picked_packages' AND COALESCE(column_comment,'') <> '已拣箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''已拣箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='in_transit_packages' AND COALESCE(column_comment,'') <> '在途箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN in_transit_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''在途箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='receiving_packages' AND COALESCE(column_comment,'') <> '收货处理中箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''收货处理中箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='procurement_packages' AND COALESCE(column_comment,'') <> '采购业务占用箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN procurement_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''采购业务占用箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND COALESCE(table_comment,'') <> '托盘库位批次库存明细'), 'ALTER TABLE ll_inventory_position COMMENT = ''托盘库位批次库存明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='balance_id' AND COALESCE(column_comment,'') <> '库存余额记录ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN balance_id BIGINT NOT NULL COMMENT ''库存余额记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='batch_no' AND COALESCE(column_comment,'') <> '库存批次号'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN batch_no VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''库存批次号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='warehouse_location_id' AND COALESCE(column_comment,'') <> '仓库库位ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''仓库库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='warehouse_location_code' AND COALESCE(column_comment,'') <> '仓库库位编码'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN warehouse_location_code VARCHAR(128) NOT NULL COMMENT ''仓库库位编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='available_packages' AND COALESCE(column_comment,'') <> '可用箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN available_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''可用箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='hold_packages' AND COALESCE(column_comment,'') <> '冻结箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''冻结箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='allocated_packages' AND COALESCE(column_comment,'') <> '已分配箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''已分配箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='picked_packages' AND COALESCE(column_comment,'') <> '已拣箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''已拣箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='receiving_packages' AND COALESCE(column_comment,'') <> '收货处理中箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''收货处理中箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND COALESCE(table_comment,'') <> '不可变库存变动流水'), 'ALTER TABLE ll_inventory_ledger COMMENT = ''不可变库存变动流水''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='balance_id' AND COALESCE(column_comment,'') <> '库存余额记录ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN balance_id BIGINT NOT NULL COMMENT ''库存余额记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN position_id BIGINT NULL COMMENT ''库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL COMMENT ''入仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN pallet_id BIGINT NULL COMMENT ''托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN pallet_no VARCHAR(64) NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='batch_no' AND COALESCE(column_comment,'') <> '库存批次号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN batch_no VARCHAR(128) NULL COMMENT ''库存批次号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='warehouse_location_code' AND COALESCE(column_comment,'') <> '仓库库位编码'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN warehouse_location_code VARCHAR(128) NULL COMMENT ''仓库库位编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN carton_mark VARCHAR(128) NULL COMMENT ''箱唛''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_packages' AND COALESCE(column_comment,'') <> '箱内箱数'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN carton_packages BIGINT NULL COMMENT ''箱内箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='business_type' AND COALESCE(column_comment,'') <> '业务类型'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN business_type VARCHAR(32) NOT NULL COMMENT ''业务类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='business_order_id' AND COALESCE(column_comment,'') <> '业务单据ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN business_order_id BIGINT NULL COMMENT ''业务单据ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='business_order_no' AND COALESCE(column_comment,'') <> '业务单号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN business_order_no VARCHAR(128) NULL COMMENT ''业务单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_no' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_no VARCHAR(128) NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='outbound_order_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN outbound_order_no VARCHAR(128) NULL COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='source_event_id' AND COALESCE(column_comment,'') <> '来源事件ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN source_event_id VARCHAR(128) NULL COMMENT ''来源事件ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='status_from' AND COALESCE(column_comment,'') <> '操作前状态'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN status_from VARCHAR(32) NULL COMMENT ''操作前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='status_to' AND COALESCE(column_comment,'') <> '操作后状态'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN status_to VARCHAR(32) NULL COMMENT ''操作后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='before_packages' AND COALESCE(column_comment,'') <> '变动前箱数'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN before_packages BIGINT NOT NULL COMMENT ''变动前箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='delta_packages' AND COALESCE(column_comment,'') <> '箱数变动量'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN delta_packages BIGINT NOT NULL COMMENT ''箱数变动量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='after_packages' AND COALESCE(column_comment,'') <> '变动后箱数'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN after_packages BIGINT NOT NULL COMMENT ''变动后箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='occurred_at' AND COALESCE(column_comment,'') <> '业务发生时间'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN occurred_at DATETIME(3) NOT NULL COMMENT ''业务发生时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND COALESCE(table_comment,'') <> '库存命令幂等记录'), 'ALTER TABLE ll_inventory_operation COMMENT = ''库存命令幂等记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='operation_type' AND COALESCE(column_comment,'') <> '操作类型'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN operation_type VARCHAR(32) NOT NULL COMMENT ''操作类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN request_hash VARCHAR(128) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='status' AND COALESCE(column_comment,'') <> '库存命令处理状态'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PROCESSING'' COMMENT ''库存命令处理状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='result_json' AND COALESCE(column_comment,'') <> '处理结果JSON'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN result_json LONGTEXT NULL COMMENT ''处理结果JSON''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='last_error' AND COALESCE(column_comment,'') <> '最后错误信息'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN last_error VARCHAR(2000) NULL COMMENT ''最后错误信息''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND COALESCE(table_comment,'') <> '入仓PDA库存实收快照'), 'ALTER TABLE ll_inbound_stock_receipt COMMENT = ''入仓PDA库存实收快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入仓计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN task_id BIGINT NOT NULL COMMENT ''PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='allocation_id' AND COALESCE(column_comment,'') <> '分配记录ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN allocation_id BIGINT NOT NULL COMMENT ''分配记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入仓计划明细ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN line_id BIGINT NOT NULL COMMENT ''入仓计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN warehouse_pallet_id BIGINT NOT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='warehouse_location_id' AND COALESCE(column_comment,'') <> '仓库库位ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''仓库库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='warehouse_location_code' AND COALESCE(column_comment,'') <> '仓库库位编码'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN warehouse_location_code VARCHAR(128) NULL COMMENT ''仓库库位编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='batch_no' AND COALESCE(column_comment,'') <> '库存批次号'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN batch_no VARCHAR(128) NOT NULL COMMENT ''库存批次号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_packages' AND COALESCE(column_comment,'') <> '实收箱数'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_packages BIGINT NOT NULL COMMENT ''实收箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='inventory_balance_id' AND COALESCE(column_comment,'') <> '库存余额记录ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inventory_balance_id BIGINT NOT NULL COMMENT ''库存余额记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inventory_position_id BIGINT NOT NULL COMMENT ''库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='inventory_operation_key' AND COALESCE(column_comment,'') <> '库存操作幂等键'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inventory_operation_key VARCHAR(255) NOT NULL COMMENT ''库存操作幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_at' AND COALESCE(column_comment,'') <> '实收时间'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_at DATETIME(3) NOT NULL COMMENT ''实收时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND COALESCE(table_comment,'') <> '尾程派车单'), 'ALTER TABLE ll_dispatch_order COMMENT = ''尾程派车单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='dispatch_no' AND COALESCE(column_comment,'') <> '派车单号'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN dispatch_no VARCHAR(64) NOT NULL COMMENT ''派车单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='vehicle_plate' AND COALESCE(column_comment,'') <> '车牌号'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='driver_name' AND COALESCE(column_comment,'') <> '司机姓名'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='driver_phone' AND COALESCE(column_comment,'') <> '司机电话'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN driver_phone VARCHAR(64) NULL COMMENT ''司机电话''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='carrier_name' AND COALESCE(column_comment,'') <> '承运商名称'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN carrier_name VARCHAR(255) NULL COMMENT ''承运商名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='route' AND COALESCE(column_comment,'') <> '运输路线'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN route VARCHAR(255) NULL COMMENT ''运输路线''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='depart_at' AND COALESCE(column_comment,'') <> '发车时间'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN depart_at DATETIME(3) NULL COMMENT ''发车时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='fba_appointment_no' AND COALESCE(column_comment,'') <> 'FBA预约号'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN fba_appointment_no VARCHAR(64) NULL COMMENT ''FBA预约号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='fba_appointment_status' AND COALESCE(column_comment,'') <> 'FBA预约状态'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN fba_appointment_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''FBA预约状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='vehicle_appointment_status' AND COALESCE(column_comment,'') <> '车辆预约状态'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''车辆预约状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到达时间'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_DEPARTURE'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='item_count' AND COALESCE(column_comment,'') <> '明细数量'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN item_count INT NOT NULL DEFAULT 0 COMMENT ''明细数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托盘数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT ''托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count' AND COALESCE(column_comment,'') <> 'PDA累计已拣箱数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''PDA累计已拣箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count' AND COALESCE(column_comment,'') <> '累计已出库箱数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason' AND COALESCE(column_comment,'') <> '部分完成原因'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN partial_completion_reason VARCHAR(1000) NULL COMMENT ''部分完成原因''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND COALESCE(table_comment,'') <> '派车货物冻结明细'), 'ALTER TABLE ll_dispatch_item COMMENT = ''派车货物冻结明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN dispatch_order_id BIGINT NOT NULL COMMENT ''派车单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN plan_id BIGINT NULL COMMENT ''入仓计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='plan_pallet_id' AND COALESCE(column_comment,'') <> '计划托盘ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN plan_pallet_id BIGINT NULL COMMENT ''计划托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN order_id BIGINT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_no' AND COALESCE(column_comment,'') <> '订单号'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN order_no VARCHAR(64) NULL COMMENT ''订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs' AND COALESCE(column_comment,'') <> '关联订单信息'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN order_refs VARCHAR(2000) NULL COMMENT ''关联订单信息''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_stage' AND COALESCE(column_comment,'') <> '数据来源阶段'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN source_stage VARCHAR(32) NOT NULL COMMENT ''数据来源阶段''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND COALESCE(table_comment,'') <> '派车托盘排他锁'), 'ALTER TABLE ll_dispatch_pallet_lock COMMENT = ''派车托盘排他锁''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='source_pallet_key' AND COALESCE(column_comment,'') <> '来源托盘业务键'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN source_pallet_key VARCHAR(160) NOT NULL COMMENT ''来源托盘业务键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='active_key' AND COALESCE(column_comment,'') <> '生效中的资源排他键'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN active_key VARCHAR(160) NULL COMMENT ''生效中的资源排他键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN plan_id BIGINT NULL COMMENT ''入仓计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='plan_pallet_id' AND COALESCE(column_comment,'') <> '计划托盘ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN plan_pallet_id BIGINT NULL COMMENT ''计划托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NOT NULL COMMENT ''派车单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='locked_at' AND COALESCE(column_comment,'') <> '锁定时间'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN locked_at DATETIME(3) NOT NULL COMMENT ''锁定时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='released_at' AND COALESCE(column_comment,'') <> '释放时间'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN released_at DATETIME(3) NULL COMMENT ''释放时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND COALESCE(table_comment,'') <> '派车PDA拣货任务'), 'ALTER TABLE ll_dispatch_pda_task COMMENT = ''派车PDA拣货任务''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN dispatch_order_id BIGINT NOT NULL COMMENT ''派车单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='task_no' AND COALESCE(column_comment,'') <> 'PDA任务号'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN task_no VARCHAR(64) NOT NULL COMMENT ''PDA任务号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '仓库ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN warehouse_id VARCHAR(128) NULL COMMENT ''仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='operator_name' AND COALESCE(column_comment,'') <> '操作人姓名'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN operator_name VARCHAR(128) NULL COMMENT ''操作人姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='pushed_at' AND COALESCE(column_comment,'') <> '任务下发时间'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN pushed_at DATETIME(3) NOT NULL COMMENT ''任务下发时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='completed_at' AND COALESCE(column_comment,'') <> '完成时间'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''完成时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND COALESCE(table_comment,'') <> '派车PDA拣货记录'), 'ALTER TABLE ll_dispatch_pda_pick COMMENT = ''派车PDA拣货记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN task_id BIGINT NOT NULL COMMENT ''PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='dispatch_item_id' AND COALESCE(column_comment,'') <> '派车明细ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN dispatch_item_id BIGINT NOT NULL COMMENT ''派车明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN inventory_position_id BIGINT NOT NULL COMMENT ''库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='packages' AND COALESCE(column_comment,'') <> '本次操作箱数'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN packages BIGINT NOT NULL COMMENT ''本次操作箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '产品数量'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''产品数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='picked_at' AND COALESCE(column_comment,'') <> '拣货时间'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN picked_at DATETIME(3) NOT NULL COMMENT ''拣货时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND COALESCE(table_comment,'') <> '派车PDA操作幂等记录'), 'ALTER TABLE ll_dispatch_pda_operation COMMENT = ''派车PDA操作幂等记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN task_id BIGINT NULL COMMENT ''PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='operation_type' AND COALESCE(column_comment,'') <> 'PDA操作类型'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN operation_type VARCHAR(32) NOT NULL COMMENT ''PDA操作类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='result_json' AND COALESCE(column_comment,'') <> '处理结果JSON'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN result_json LONGTEXT NULL COMMENT ''处理结果JSON''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='status' AND COALESCE(column_comment,'') <> 'PDA操作处理状态'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''PDA操作处理状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND COALESCE(table_comment,'') <> 'PDA拣货订单归属记录'), 'ALTER TABLE ll_dispatch_pda_pick_order COMMENT = ''PDA拣货订单归属记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='pick_id' AND COALESCE(column_comment,'') <> 'PDA拣货记录ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN pick_id BIGINT NOT NULL COMMENT ''PDA拣货记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='receipt_id' AND COALESCE(column_comment,'') <> '入仓实收记录ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN receipt_id BIGINT NOT NULL COMMENT ''入仓实收记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='packages' AND COALESCE(column_comment,'') <> '本次操作箱数'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN packages BIGINT NOT NULL COMMENT ''本次操作箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '产品数量'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''产品数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND COALESCE(table_comment,'') <> '尾程出库单'), 'ALTER TABLE ll_outbound_order COMMENT = ''尾程出库单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN outbound_no VARCHAR(64) NOT NULL COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_order_id BIGINT NOT NULL COMMENT ''派车单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='dispatch_no' AND COALESCE(column_comment,'') <> '派车单号'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_no VARCHAR(64) NOT NULL COMMENT ''派车单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_OUTBOUND'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托盘数'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT ''托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND COALESCE(table_comment,'') <> '出库托盘明细'), 'ALTER TABLE ll_outbound_pallet_item COMMENT = ''出库托盘明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='outbound_order_id' AND COALESCE(column_comment,'') <> '出库单ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN outbound_order_id BIGINT NOT NULL COMMENT ''出库单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='order_refs' AND COALESCE(column_comment,'') <> '关联订单信息'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN order_refs VARCHAR(2000) NULL COMMENT ''关联订单信息''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND COALESCE(table_comment,'') <> '尾程派送单'), 'ALTER TABLE ll_delivery_order COMMENT = ''尾程派送单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='delivery_no' AND COALESCE(column_comment,'') <> '派送单号'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_no VARCHAR(64) NOT NULL COMMENT ''派送单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='outbound_order_id' AND COALESCE(column_comment,'') <> '出库单ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN outbound_order_id BIGINT NOT NULL COMMENT ''出库单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='outbound_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN outbound_no VARCHAR(64) NOT NULL COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT NOT NULL COMMENT ''派车单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_no' AND COALESCE(column_comment,'') <> '派车单号'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_no VARCHAR(64) NOT NULL COMMENT ''派车单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_DELIVERY'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托盘数'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT ''托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND COALESCE(table_comment,'') <> '客户转仓单'), 'ALTER TABLE ll_warehouse_transfer_order COMMENT = ''客户转仓单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='transfer_no' AND COALESCE(column_comment,'') <> '转仓单号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN transfer_no VARCHAR(64) NOT NULL COMMENT ''转仓单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN inbound_order_id BIGINT NOT NULL COMMENT ''入仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='lastleg_order_id' AND COALESCE(column_comment,'') <> '尾程订单ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN lastleg_order_id BIGINT NOT NULL COMMENT ''尾程订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='lastleg_order_no' AND COALESCE(column_comment,'') <> '尾程订单号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN lastleg_order_no VARCHAR(32) NOT NULL COMMENT ''尾程订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='operating_warehouse_id' AND COALESCE(column_comment,'') <> '作业仓库ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN operating_warehouse_id VARCHAR(128) NOT NULL COMMENT ''作业仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NOT NULL COMMENT ''FBA仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='delivery_address' AND COALESCE(column_comment,'') <> '派送地址'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''派送地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN package_count INT NOT NULL COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='delivery_method' AND COALESCE(column_comment,'') <> '派送方式'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''派送方式''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到达时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NOT NULL COMMENT ''预计到达时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_shipment_no' AND COALESCE(column_comment,'') <> 'FBA货件编号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_shipment_no VARCHAR(128) NOT NULL COMMENT ''FBA货件编号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='tracking_no' AND COALESCE(column_comment,'') <> '物流跟踪号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN tracking_no VARCHAR(255) NULL COMMENT ''物流跟踪号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_box_nos' AND COALESCE(column_comment,'') <> 'FBA箱号集合'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_box_nos VARCHAR(2000) NULL COMMENT ''FBA箱号集合''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='need_relabel' AND COALESCE(column_comment,'') <> '是否需要换标'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT ''是否需要换标''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_application_id' AND COALESCE(column_comment,'') <> '换标申请单ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN relabel_application_id BIGINT NULL COMMENT ''换标申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_no' AND COALESCE(column_comment,'') <> '换标申请单号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN relabel_no VARCHAR(64) NULL COMMENT ''换标申请单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_REVIEW'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key' AND COALESCE(column_comment,'') <> '库存分配幂等键'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_request_hash' AND COALESCE(column_comment,'') <> '库存分配请求哈希'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN allocation_request_hash CHAR(64) NULL COMMENT ''库存分配请求哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_idempotency_key' AND COALESCE(column_comment,'') <> '出库幂等键'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN outbound_idempotency_key VARCHAR(255) NULL COMMENT ''出库幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_request_hash' AND COALESCE(column_comment,'') <> '出库请求哈希'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN outbound_request_hash CHAR(64) NULL COMMENT ''出库请求哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at' AND COALESCE(column_comment,'') <> '出库时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND COALESCE(table_comment,'') <> '转仓来源历史订单绑定'), 'ALTER TABLE ll_warehouse_transfer_source_order COMMENT = ''转仓来源历史订单绑定''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN transfer_order_id BIGINT NOT NULL COMMENT ''转仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='source_lastleg_order_id' AND COALESCE(column_comment,'') <> '来源尾程订单ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN source_lastleg_order_id BIGINT NOT NULL COMMENT ''来源尾程订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='source_lastleg_order_no' AND COALESCE(column_comment,'') <> '来源尾程订单号'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN source_lastleg_order_no VARCHAR(32) NOT NULL COMMENT ''来源尾程订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN inbound_order_id BIGINT NOT NULL COMMENT ''入仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND COALESCE(table_comment,'') <> '转仓货物冻结明细'), 'ALTER TABLE ll_warehouse_transfer_item COMMENT = ''转仓货物冻结明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN transfer_order_id BIGINT NOT NULL COMMENT ''转仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '产品中文名称'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN product_name_zh VARCHAR(255) NOT NULL COMMENT ''产品中文名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN package_count INT NOT NULL COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='shipping_mark' AND COALESCE(column_comment,'') <> '运输唛头'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT ''运输唛头''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND COALESCE(table_comment,'') <> '转仓单来源库存分配记录'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation COMMENT = ''转仓单来源库存分配记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='id' AND COALESCE(column_comment,'') <> '雪花主键'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''雪花主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN transfer_order_id BIGINT NOT NULL COMMENT ''转仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '来源库存仓位记录ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inventory_position_id BIGINT NOT NULL COMMENT ''来源库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='inventory_balance_id' AND COALESCE(column_comment,'') <> '来源库存余额记录ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inventory_balance_id BIGINT NOT NULL COMMENT ''来源库存余额记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '来源入仓单ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inbound_order_id BIGINT NOT NULL COMMENT ''来源入仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '来源入仓单号快照'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''来源入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '来源托盘ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''来源托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '来源托盘号快照'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT ''来源托盘号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='package_count' AND COALESCE(column_comment,'') <> '分配箱数'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN package_count INT NOT NULL COMMENT ''分配箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='status' AND COALESCE(column_comment,'') <> '分配状态'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''RESERVED'' COMMENT ''分配状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='active_position_key' AND COALESCE(column_comment,'') <> '生效中的仓位排他键'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN active_position_key VARCHAR(255) NULL COMMENT ''生效中的仓位排他键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='allocated_at' AND COALESCE(column_comment,'') <> '分配时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN allocated_at DATETIME(3) NOT NULL COMMENT ''分配时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='shipped_at' AND COALESCE(column_comment,'') <> '出库时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN shipped_at DATETIME(3) NULL COMMENT ''出库时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='received_at' AND COALESCE(column_comment,'') <> '收货时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN received_at DATETIME(3) NULL COMMENT ''收货时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND COALESCE(table_comment,'') <> '转仓来源仓位的目标库存收货记录'), 'ALTER TABLE ll_warehouse_transfer_receipt COMMENT = ''转仓来源仓位的目标库存收货记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='id' AND COALESCE(column_comment,'') <> '雪花主键'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN id BIGINT NOT NULL COMMENT ''雪花主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN transfer_order_id BIGINT NOT NULL COMMENT ''转仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='allocation_id' AND COALESCE(column_comment,'') <> '库存分配记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN allocation_id BIGINT NOT NULL COMMENT ''库存分配记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='source_inventory_position_id' AND COALESCE(column_comment,'') <> '来源库存仓位记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN source_inventory_position_id BIGINT NOT NULL COMMENT ''来源库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='destination_inventory_balance_id' AND COALESCE(column_comment,'') <> '目标库存余额记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN destination_inventory_balance_id BIGINT NOT NULL COMMENT ''目标库存余额记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='destination_inventory_position_id' AND COALESCE(column_comment,'') <> '目标库存仓位记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN destination_inventory_position_id BIGINT NOT NULL COMMENT ''目标库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='package_count' AND COALESCE(column_comment,'') <> '收货箱数'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN package_count INT NOT NULL COMMENT ''收货箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '收货幂等键'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''收货幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '收货请求哈希'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''收货请求哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='inventory_operation_key' AND COALESCE(column_comment,'') <> '库存操作幂等键'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN inventory_operation_key VARCHAR(255) NOT NULL COMMENT ''库存操作幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='received_at' AND COALESCE(column_comment,'') <> '收货时间'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN received_at DATETIME(3) NOT NULL COMMENT ''收货时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND COALESCE(table_comment,'') <> '客户换标申请单'), 'ALTER TABLE ll_relabel_application COMMENT = ''客户换标申请单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='relabel_no' AND COALESCE(column_comment,'') <> '换标申请单号'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN relabel_no VARCHAR(64) NOT NULL COMMENT ''换标申请单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='lastleg_order_id' AND COALESCE(column_comment,'') <> '尾程订单ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN lastleg_order_id BIGINT NOT NULL COMMENT ''尾程订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_order_no' AND COALESCE(column_comment,'') <> '客户订单号'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN customer_order_no VARCHAR(128) NOT NULL COMMENT ''客户订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='label_deadline' AND COALESCE(column_comment,'') <> '换标截止时间'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN label_deadline DATETIME(3) NOT NULL COMMENT ''换标截止时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '联系人姓名'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN contact_name VARCHAR(128) NOT NULL COMMENT ''联系人姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='contact_phone' AND COALESCE(column_comment,'') <> '联系人电话'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN contact_phone VARCHAR(64) NOT NULL COMMENT ''联系人电话''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_PROCESSING'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='item_count' AND COALESCE(column_comment,'') <> '明细数量'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN item_count INT NOT NULL DEFAULT 0 COMMENT ''明细数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_package_count' AND COALESCE(column_comment,'') <> '总箱数'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN total_package_count INT NOT NULL DEFAULT 0 COMMENT ''总箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='notified_package_count' AND COALESCE(column_comment,'') <> '已通知箱数'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN notified_package_count INT NOT NULL DEFAULT 0 COMMENT ''已通知箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND COALESCE(table_comment,'') <> '换标货物冻结明细'), 'ALTER TABLE ll_relabel_item COMMENT = ''换标货物冻结明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN application_id BIGINT NOT NULL COMMENT ''申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='product_image_file_id' AND COALESCE(column_comment,'') <> '产品图片文件ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_image_file_id VARCHAR(128) NULL COMMENT ''产品图片文件ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='product_image_file_name' AND COALESCE(column_comment,'') <> '产品图片文件名'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_image_file_name VARCHAR(255) NULL COMMENT ''产品图片文件名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='product_image_url' AND COALESCE(column_comment,'') <> '产品图片地址'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_image_url VARCHAR(1000) NULL COMMENT ''产品图片地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '产品中文名称'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_name_zh VARCHAR(255) NOT NULL COMMENT ''产品中文名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='total_package_count' AND COALESCE(column_comment,'') <> '总箱数'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN total_package_count INT NOT NULL COMMENT ''总箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='notified_package_count' AND COALESCE(column_comment,'') <> '已通知箱数'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN notified_package_count INT NOT NULL COMMENT ''已通知箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='shipment_no' AND COALESCE(column_comment,'') <> '货件编号'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NOT NULL COMMENT ''货件编号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='tracking_code' AND COALESCE(column_comment,'') <> '物流跟踪编码'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN tracking_code VARCHAR(255) NULL COMMENT ''物流跟踪编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='new_sku_code' AND COALESCE(column_comment,'') <> '换标后SKU编码'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code VARCHAR(128) NOT NULL COMMENT ''换标后SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='label_requirement' AND COALESCE(column_comment,'') <> '换标要求'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN label_requirement VARCHAR(1000) NULL COMMENT ''换标要求''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='relabel_file_id' AND COALESCE(column_comment,'') <> '换标文件ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN relabel_file_id VARCHAR(128) NULL COMMENT ''换标文件ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='relabel_file_name' AND COALESCE(column_comment,'') <> '换标文件名'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN relabel_file_name VARCHAR(255) NULL COMMENT ''换标文件名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='relabel_file_url' AND COALESCE(column_comment,'') <> '换标文件地址'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN relabel_file_url VARCHAR(1000) NULL COMMENT ''换标文件地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND COALESCE(table_comment,'') <> '换标状态变更历史'), 'ALTER TABLE ll_relabel_status_history COMMENT = ''换标状态变更历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN application_id BIGINT NOT NULL COMMENT ''申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND COALESCE(table_comment,'') <> '一件代发托盘档案'), 'ALTER TABLE ll_dropship_order_profile COMMENT = ''一件代发托盘档案''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='source_pallet_no' AND COALESCE(column_comment,'') <> '来源托盘号快照'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN source_pallet_no VARCHAR(128) NOT NULL COMMENT ''来源托盘号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN inventory_position_id BIGINT NULL COMMENT ''库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND COALESCE(table_comment,'') <> '一件代发箱唛明细'), 'ALTER TABLE ll_dropship_carton COMMENT = ''一件代发箱唛明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='profile_id' AND COALESCE(column_comment,'') <> '托盘档案ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN profile_id BIGINT NOT NULL COMMENT ''托盘档案ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN carton_mark VARCHAR(128) NOT NULL COMMENT ''箱唛''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='carton_sequence' AND COALESCE(column_comment,'') <> '箱唛序号'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN carton_sequence INT NOT NULL COMMENT ''箱唛序号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='outbound_order_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN outbound_order_no VARCHAR(128) NULL COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND COALESCE(table_comment,'') <> '一件代发SKU库存汇总'), 'ALTER TABLE ll_dropship_sku_inventory COMMENT = ''一件代发SKU库存汇总''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='profile_id' AND COALESCE(column_comment,'') <> '托盘档案ID'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN profile_id BIGINT NOT NULL COMMENT ''托盘档案ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='product_name' AND COALESCE(column_comment,'') <> '产品名称'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN product_name VARCHAR(255) NULL COMMENT ''产品名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN package_count BIGINT NOT NULL COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '产品数量'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN product_quantity BIGINT NOT NULL COMMENT ''产品数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND COALESCE(table_comment,'') <> '一件代发箱唛库存流水'), 'ALTER TABLE ll_dropship_carton_ledger COMMENT = ''一件代发箱唛库存流水''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='carton_id' AND COALESCE(column_comment,'') <> '箱唛记录ID'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN carton_id BIGINT NOT NULL COMMENT ''箱唛记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN carton_mark VARCHAR(128) NOT NULL COMMENT ''箱唛''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='operation_type' AND COALESCE(column_comment,'') <> '操作类型'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN operation_type VARCHAR(32) NOT NULL COMMENT ''操作类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='before_packages' AND COALESCE(column_comment,'') <> '变动前箱数'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN before_packages BIGINT NOT NULL COMMENT ''变动前箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='delta_packages' AND COALESCE(column_comment,'') <> '箱数变动量'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN delta_packages BIGINT NOT NULL COMMENT ''箱数变动量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='after_packages' AND COALESCE(column_comment,'') <> '变动后箱数'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN after_packages BIGINT NOT NULL COMMENT ''变动后箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='business_order_no' AND COALESCE(column_comment,'') <> '业务单号'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN business_order_no VARCHAR(128) NULL COMMENT ''业务单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='occurred_at' AND COALESCE(column_comment,'') <> '业务发生时间'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN occurred_at DATETIME(3) NOT NULL COMMENT ''业务发生时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND COALESCE(table_comment,'') <> '货物废弃申请单'), 'ALTER TABLE ll_cargo_discard_application COMMENT = ''货物废弃申请单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='discard_no' AND COALESCE(column_comment,'') <> '废弃申请单号'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN discard_no VARCHAR(64) NOT NULL COMMENT ''废弃申请单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='inbound_codes' AND COALESCE(column_comment,'') <> '入仓单号集合'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN inbound_codes VARCHAR(2000) NOT NULL COMMENT ''入仓单号集合''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='total_packages' AND COALESCE(column_comment,'') <> '总箱数'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN total_packages INT NOT NULL COMMENT ''总箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='fee_per_package' AND COALESCE(column_comment,'') <> '每箱处理费用'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN fee_per_package DECIMAL(18,2) NOT NULL COMMENT ''每箱处理费用''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='total_fee' AND COALESCE(column_comment,'') <> '费用合计'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN total_fee DECIMAL(18,2) NOT NULL COMMENT ''费用合计''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='currency' AND COALESCE(column_comment,'') <> '币种'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN currency CHAR(3) NOT NULL DEFAULT ''USD'' COMMENT ''币种''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_REVIEW'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='reviewed_by' AND COALESCE(column_comment,'') <> '审核人ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='review_remark' AND COALESCE(column_comment,'') <> '审核备注'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='reviewed_at' AND COALESCE(column_comment,'') <> '审核时间'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND COALESCE(table_comment,'') <> '货物废弃SKU明细'), 'ALTER TABLE ll_cargo_discard_item COMMENT = ''货物废弃SKU明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN application_id BIGINT NOT NULL COMMENT ''申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='profile_id' AND COALESCE(column_comment,'') <> '托盘档案ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN profile_id BIGINT NOT NULL COMMENT ''托盘档案ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='sku_inventory_id' AND COALESCE(column_comment,'') <> 'SKU库存记录ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN sku_inventory_id BIGINT NOT NULL COMMENT ''SKU库存记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='product_name' AND COALESCE(column_comment,'') <> '产品名称'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN product_name VARCHAR(255) NULL COMMENT ''产品名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='available_packages_snapshot' AND COALESCE(column_comment,'') <> '可用箱数快照'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN available_packages_snapshot INT NOT NULL COMMENT ''可用箱数快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='discard_packages' AND COALESCE(column_comment,'') <> '废弃箱数'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN discard_packages INT NOT NULL COMMENT ''废弃箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND COALESCE(table_comment,'') <> '货物废弃锁定箱明细'), 'ALTER TABLE ll_cargo_discard_carton COMMENT = ''货物废弃锁定箱明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN application_id BIGINT NOT NULL COMMENT ''申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='item_id' AND COALESCE(column_comment,'') <> '明细ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN item_id BIGINT NOT NULL COMMENT ''明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='carton_id' AND COALESCE(column_comment,'') <> '箱唛记录ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN carton_id BIGINT NOT NULL COMMENT ''箱唛记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN carton_mark VARCHAR(128) NOT NULL COMMENT ''箱唛''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND COALESCE(table_comment,'') <> '货物废弃审批历史'), 'ALTER TABLE ll_cargo_discard_status_history COMMENT = ''货物废弃审批历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN application_id BIGINT NOT NULL COMMENT ''申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND COALESCE(table_comment,'') <> '海外采购订单'), 'ALTER TABLE ll_overseas_procurement_order COMMENT = ''海外采购订单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_name' AND COALESCE(column_comment,'') <> '客户名称快照'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='order_no' AND COALESCE(column_comment,'') <> '订单号'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN order_no VARCHAR(64) NOT NULL COMMENT ''订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='purchase_category' AND COALESCE(column_comment,'') <> '采购品类'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_category VARCHAR(128) NOT NULL COMMENT ''采购品类''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='product_name' AND COALESCE(column_comment,'') <> '产品名称'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN product_name VARCHAR(255) NOT NULL COMMENT ''产品名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='product_specification' AND COALESCE(column_comment,'') <> '产品规格'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN product_specification VARCHAR(500) NULL COMMENT ''产品规格''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='product_url' AND COALESCE(column_comment,'') <> '产品链接'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN product_url VARCHAR(2000) NULL COMMENT ''产品链接''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='purchase_quantity' AND COALESCE(column_comment,'') <> '采购数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_quantity BIGINT NOT NULL COMMENT ''采购数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='budget_unit_price' AND COALESCE(column_comment,'') <> '预算单价'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN budget_unit_price DECIMAL(18,2) NULL COMMENT ''预算单价''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='currency' AND COALESCE(column_comment,'') <> '币种'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN currency CHAR(3) NOT NULL DEFAULT ''USD'' COMMENT ''币种''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='receiving_address' AND COALESCE(column_comment,'') <> '收货地址'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN receiving_address VARCHAR(1000) NULL COMMENT ''收货地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '联系人姓名'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN contact_name VARCHAR(128) NOT NULL COMMENT ''联系人姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='contact_phone' AND COALESCE(column_comment,'') <> '联系人电话'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN contact_phone VARCHAR(64) NOT NULL COMMENT ''联系人电话''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='purchase_warehouse_id' AND COALESCE(column_comment,'') <> '采购入仓仓库ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_warehouse_id VARCHAR(128) NULL COMMENT ''采购入仓仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='purchase_warehouse_name' AND COALESCE(column_comment,'') <> '采购入仓仓库名称'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_warehouse_name VARCHAR(255) NULL COMMENT ''采购入仓仓库名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='inbound_quantity' AND COALESCE(column_comment,'') <> '累计入库数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''累计入库数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='outbound_quantity' AND COALESCE(column_comment,'') <> '累计出库数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''累计出库数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='domestic_carrier' AND COALESCE(column_comment,'') <> '境内承运商'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN domestic_carrier VARCHAR(128) NULL COMMENT ''境内承运商''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='domestic_tracking_no' AND COALESCE(column_comment,'') <> '境内物流单号'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN domestic_tracking_no VARCHAR(128) NULL COMMENT ''境内物流单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='overseas_carrier' AND COALESCE(column_comment,'') <> '海外承运商'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN overseas_carrier VARCHAR(128) NULL COMMENT ''海外承运商''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='overseas_tracking_no' AND COALESCE(column_comment,'') <> '海外物流单号'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN overseas_tracking_no VARCHAR(128) NULL COMMENT ''海外物流单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到达时间'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_REVIEW'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='reviewed_by' AND COALESCE(column_comment,'') <> '审核人ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_remark' AND COALESCE(column_comment,'') <> '审核备注'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='reviewed_at' AND COALESCE(column_comment,'') <> '审核时间'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND COALESCE(table_comment,'') <> '海外采购出库记录'), 'ALTER TABLE ll_overseas_procurement_outbound COMMENT = ''海外采购出库记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN quantity BIGINT NOT NULL COMMENT ''数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='outbound_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN outbound_no VARCHAR(128) NOT NULL COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='carrier' AND COALESCE(column_comment,'') <> '承运商'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN carrier VARCHAR(128) NULL COMMENT ''承运商''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='tracking_no' AND COALESCE(column_comment,'') <> '物流跟踪号'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN tracking_no VARCHAR(128) NULL COMMENT ''物流跟踪号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='outbound_at' AND COALESCE(column_comment,'') <> '出库时间'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN outbound_at DATETIME(3) NOT NULL COMMENT ''出库时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND COALESCE(table_comment,'') <> '海外采购审核历史'), 'ALTER TABLE ll_overseas_procurement_status_history COMMENT = ''海外采购审核历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND COALESCE(table_comment,'') <> '美国卡车运输订单'), 'ALTER TABLE ll_us_truck_order COMMENT = ''美国卡车运输订单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='order_no' AND COALESCE(column_comment,'') <> '订单号'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN order_no VARCHAR(64) NOT NULL COMMENT ''订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='transport_type' AND COALESCE(column_comment,'') <> '运输类型'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN transport_type VARCHAR(32) NOT NULL COMMENT ''运输类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='origin' AND COALESCE(column_comment,'') <> '起运地'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN origin VARCHAR(255) NOT NULL COMMENT ''起运地''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='origin_address' AND COALESCE(column_comment,'') <> '起运地址'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN origin_address VARCHAR(500) NULL COMMENT ''起运地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='destination' AND COALESCE(column_comment,'') <> '目的地'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN destination VARCHAR(255) NOT NULL COMMENT ''目的地''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='destination_address' AND COALESCE(column_comment,'') <> '目的地址'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN destination_address VARCHAR(500) NULL COMMENT ''目的地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='container_type' AND COALESCE(column_comment,'') <> '历史兼容设备类型,零担托盘业务不采集'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type VARCHAR(32) NULL COMMENT ''历史兼容设备类型,零担托盘业务不采集''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '客户申报托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 1 COMMENT ''客户申报托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '可选箱数,零或空值均有效'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN package_count INT NULL COMMENT ''可选箱数,零或空值均有效''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='received_pallet_count' AND COALESCE(column_comment,'') <> 'PDA实收托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN received_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''PDA实收托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='allocated_pallet_count' AND COALESCE(column_comment,'') <> '已分配到合车行程的托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''已分配到合车行程的托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='shipped_pallet_count' AND COALESCE(column_comment,'') <> '已出库托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''已出库托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='total_weight_kg' AND COALESCE(column_comment,'') <> '总重量(千克)'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='total_volume_cbm' AND COALESCE(column_comment,'') <> '总体积(立方米)'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='budget_amount' AND COALESCE(column_comment,'') <> '预算金额'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN budget_amount DECIMAL(18,2) NULL COMMENT ''预算金额''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='currency' AND COALESCE(column_comment,'') <> '币种'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN currency VARCHAR(3) NOT NULL DEFAULT ''USD'' COMMENT ''币种''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '联系人姓名'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_name VARCHAR(128) NOT NULL COMMENT ''联系人姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='contact_info' AND COALESCE(column_comment,'') <> '联系人联系方式'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_info VARCHAR(128) NOT NULL COMMENT ''联系人联系方式''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='requested_date' AND COALESCE(column_comment,'') <> '要求服务日期'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN requested_date DATE NOT NULL COMMENT ''要求服务日期''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='status' AND COALESCE(column_comment,'') <> '美国卡车订单状态'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''美国卡车订单状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='receipt_idempotency_key' AND COALESCE(column_comment,'') <> '实物收货幂等键'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN receipt_idempotency_key VARCHAR(255) NULL COMMENT ''实物收货幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='receipt_request_hash' AND COALESCE(column_comment,'') <> '实物收货请求哈希'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN receipt_request_hash CHAR(64) NULL COMMENT ''实物收货请求哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND COALESCE(table_comment,'') <> '美国卡车订单托盘实收记录'), 'ALTER TABLE ll_us_truck_order_pallet COMMENT = ''美国卡车订单托盘实收记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘记录ID'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN id BIGINT NOT NULL COMMENT ''托盘记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='order_id' AND COALESCE(column_comment,'') <> '美国卡车订单ID'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''美国卡车订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '业务托盘号'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT ''业务托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='sequence_no' AND COALESCE(column_comment,'') <> '订单内托盘序号'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN sequence_no INT NOT NULL COMMENT ''订单内托盘序号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='source' AND COALESCE(column_comment,'') <> '托盘来源:订单计划或PDA超收'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN source VARCHAR(32) NOT NULL COMMENT ''托盘来源:订单计划或PDA超收''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='status' AND COALESCE(column_comment,'') <> '托盘状态:待收、已收或超收待审核'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''托盘状态:待收、已收或超收待审核''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘资源ID'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘资源ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='package_count' AND COALESCE(column_comment,'') <> '托盘箱数'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN package_count INT NULL COMMENT ''托盘箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='received_at' AND COALESCE(column_comment,'') <> '实收时间'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN received_at DATETIME(3) NULL COMMENT ''实收时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND COALESCE(table_comment,'') <> '美国卡车订单状态历史'), 'ALTER TABLE ll_us_truck_order_status_history COMMENT = ''美国卡车订单状态历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + + +USE baiyue_lastlag_warehouse; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_location' AND COALESCE(table_comment,'') <> '仓库库位表:按租户和仓库维护库位及承重信息'), 'ALTER TABLE wh_location COMMENT = ''仓库库位表:按租户和仓库维护库位及承重信息''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='id' AND COALESCE(column_comment,'') <> '库位ID(雪花ID)'), 'ALTER TABLE wh_location MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''库位ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_location MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''数据所属租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_location MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT ''所属仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='zone_code' AND COALESCE(column_comment,'') <> '仓库分区编码'), 'ALTER TABLE wh_location MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT ''仓库分区编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='location_code' AND COALESCE(column_comment,'') <> '仓库内唯一库位编码'), 'ALTER TABLE wh_location MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT ''仓库内唯一库位编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='location_type' AND COALESCE(column_comment,'') <> '库位类型'), 'ALTER TABLE wh_location MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT ''库位类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='max_load_kg' AND COALESCE(column_comment,'') <> '最大承重(千克)'), 'ALTER TABLE wh_location MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT ''最大承重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='status' AND COALESCE(column_comment,'') <> '库位状态:ACTIVE启用、INACTIVE停用'), 'ALTER TABLE wh_location MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''库位状态:ACTIVE启用、INACTIVE停用''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE wh_location MODIFY COLUMN remark VARCHAR(500) DEFAULT NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_location MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_location MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_location MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''最后修改人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_location MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_location MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''逻辑删除标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_location MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND COALESCE(table_comment,'') <> '仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'), 'ALTER TABLE wh_pallet COMMENT = ''仓库托盘表:按租户和仓库维护托盘、容量、单价及状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘ID(雪花ID)'), 'ALTER TABLE wh_pallet MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''托盘ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''数据所属租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT ''所属仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='location_id' AND COALESCE(column_comment,'') <> '同仓库内绑定的库位ID,可为空'), 'ALTER TABLE wh_pallet MODIFY COLUMN location_id BIGINT UNSIGNED DEFAULT NULL COMMENT ''同仓库内绑定的库位ID,可为空''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='pallet_code' AND COALESCE(column_comment,'') <> '仓库内唯一托盘编码'), 'ALTER TABLE wh_pallet MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''仓库内唯一托盘编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='pallet_type' AND COALESCE(column_comment,'') <> '托盘类型'), 'ALTER TABLE wh_pallet MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT ''托盘类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='capacity_cbm' AND COALESCE(column_comment,'') <> '托盘容量(立方米)'), 'ALTER TABLE wh_pallet MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT ''托盘容量(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='unit_price' AND COALESCE(column_comment,'') <> '托盘单价'), 'ALTER TABLE wh_pallet MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT ''托盘单价''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='status' AND COALESCE(column_comment,'') <> '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃'), 'ALTER TABLE wh_pallet MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''IDLE'' COMMENT ''托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE wh_pallet MODIFY COLUMN remark VARCHAR(500) DEFAULT NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> '所属FBA仓编码'), 'ALTER TABLE wh_pallet MODIFY COLUMN fba_warehouse_code VARCHAR(128) DEFAULT NULL COMMENT ''所属FBA仓编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''最后修改人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_pallet MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_pallet MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''逻辑删除标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_pallet MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND COALESCE(table_comment,'') <> '仓储资源锁定记录'), 'ALTER TABLE wh_resource_lock COMMENT = ''仓储资源锁定记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '仓库ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT ''仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN pallet_id BIGINT UNSIGNED NOT NULL COMMENT ''托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='location_id' AND COALESCE(column_comment,'') <> '库位ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN location_id BIGINT UNSIGNED NOT NULL COMMENT ''库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='business_type' AND COALESCE(column_comment,'') <> '业务类型'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN business_type VARCHAR(50) NOT NULL COMMENT ''业务类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='business_id' AND COALESCE(column_comment,'') <> '业务记录ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN business_id VARCHAR(100) NOT NULL COMMENT ''业务记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='reservation_key' AND COALESCE(column_comment,'') <> '资源预留幂等键'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN reservation_key VARCHAR(160) NOT NULL COMMENT ''资源预留幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='status' AND COALESCE(column_comment,'') <> '资源锁状态'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''资源锁状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='locked_at' AND COALESCE(column_comment,'') <> '锁定时间'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN locked_at DATETIME(3) NOT NULL COMMENT ''锁定时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='released_at' AND COALESCE(column_comment,'') <> '释放时间'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN released_at DATETIME(3) DEFAULT NULL COMMENT ''释放时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND COALESCE(table_comment,'') <> '仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'), 'ALTER TABLE wh_warehouse COMMENT = ''仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='id' AND COALESCE(column_comment,'') <> '仓库ID(雪花ID)'), 'ALTER TABLE wh_warehouse MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''仓库ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '仓库所属租户ID'), 'ALTER TABLE wh_warehouse MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''仓库所属租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='warehouse_code' AND COALESCE(column_comment,'') <> '租户内唯一仓库编码'), 'ALTER TABLE wh_warehouse MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT ''租户内唯一仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='warehouse_name' AND COALESCE(column_comment,'') <> '仓库名称'), 'ALTER TABLE wh_warehouse MODIFY COLUMN warehouse_name VARCHAR(120) NOT NULL COMMENT ''仓库名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='warehouse_type' AND COALESCE(column_comment,'') <> '仓库类型'), 'ALTER TABLE wh_warehouse MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''仓库类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='country_code' AND COALESCE(column_comment,'') <> '所在国家二字码'), 'ALTER TABLE wh_warehouse MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT ''所在国家二字码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='timezone' AND COALESCE(column_comment,'') <> '仓库所在地IANA时区'), 'ALTER TABLE wh_warehouse MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT ''仓库所在地IANA时区''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '仓库联系人'), 'ALTER TABLE wh_warehouse MODIFY COLUMN contact_name VARCHAR(64) DEFAULT NULL COMMENT ''仓库联系人''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='contact_phone' AND COALESCE(column_comment,'') <> '联系电话'), 'ALTER TABLE wh_warehouse MODIFY COLUMN contact_phone VARCHAR(32) DEFAULT NULL COMMENT ''联系电话''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='contact_email' AND COALESCE(column_comment,'') <> '联系邮箱'), 'ALTER TABLE wh_warehouse MODIFY COLUMN contact_email VARCHAR(128) DEFAULT NULL COMMENT ''联系邮箱''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='status' AND COALESCE(column_comment,'') <> '仓库状态'), 'ALTER TABLE wh_warehouse MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''仓库状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='remark' AND COALESCE(column_comment,'') <> '仓库备注'), 'ALTER TABLE wh_warehouse MODIFY COLUMN remark VARCHAR(500) DEFAULT NULL COMMENT ''仓库备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_warehouse MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_warehouse MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_warehouse MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''最后修改人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_warehouse MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_warehouse MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''逻辑删除标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_warehouse MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND COALESCE(table_comment,'') <> '仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'), 'ALTER TABLE wh_warehouse_address COMMENT = ''仓库地址表:保存运营、退货等不同用途的地址及可选经纬度''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='id' AND COALESCE(column_comment,'') <> '仓库地址ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''仓库地址ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''数据所属租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT ''所属仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='address_type' AND COALESCE(column_comment,'') <> '地址类型,例如OPERATING或RETURN'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT ''OPERATING'' COMMENT ''地址类型,例如OPERATING或RETURN''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='country_code' AND COALESCE(column_comment,'') <> '国家二字码'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT ''国家二字码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='state_province' AND COALESCE(column_comment,'') <> '省或州'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN state_province VARCHAR(100) DEFAULT NULL COMMENT ''省或州''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='city' AND COALESCE(column_comment,'') <> '城市'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN city VARCHAR(100) NOT NULL COMMENT ''城市''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='district' AND COALESCE(column_comment,'') <> '区县'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN district VARCHAR(100) DEFAULT NULL COMMENT ''区县''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='postal_code' AND COALESCE(column_comment,'') <> '邮政编码'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN postal_code VARCHAR(20) DEFAULT NULL COMMENT ''邮政编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='address_line1' AND COALESCE(column_comment,'') <> '主地址行'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN address_line1 VARCHAR(255) NOT NULL COMMENT ''主地址行''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='address_line2' AND COALESCE(column_comment,'') <> '补充地址行'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN address_line2 VARCHAR(255) DEFAULT NULL COMMENT ''补充地址行''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='latitude' AND COALESCE(column_comment,'') <> '纬度'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN latitude DECIMAL(10,7) DEFAULT NULL COMMENT ''纬度''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='longitude' AND COALESCE(column_comment,'') <> '经度'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN longitude DECIMAL(10,7) DEFAULT NULL COMMENT ''经度''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''最后修改人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''逻辑删除标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND COALESCE(table_comment,'') <> '仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'), 'ALTER TABLE wh_service_capability COMMENT = ''仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='id' AND COALESCE(column_comment,'') <> '服务能力ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''服务能力ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''数据所属租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT ''所属仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='capability_code' AND COALESCE(column_comment,'') <> '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等'), 'ALTER TABLE wh_service_capability MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT ''能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='capability_name' AND COALESCE(column_comment,'') <> '能力名称'), 'ALTER TABLE wh_service_capability MODIFY COLUMN capability_name VARCHAR(100) NOT NULL COMMENT ''能力名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='enabled' AND COALESCE(column_comment,'') <> '是否启用'), 'ALTER TABLE wh_service_capability MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='cutoff_time' AND COALESCE(column_comment,'') <> '当日作业截单时间'), 'ALTER TABLE wh_service_capability MODIFY COLUMN cutoff_time TIME DEFAULT NULL COMMENT ''当日作业截单时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='description' AND COALESCE(column_comment,'') <> '能力说明'), 'ALTER TABLE wh_service_capability MODIFY COLUMN description VARCHAR(500) DEFAULT NULL COMMENT ''能力说明''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_service_capability MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''最后修改人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_service_capability MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_service_capability MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''逻辑删除标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_service_capability MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND COALESCE(table_comment,'') <> '托盘业务绑定关系'), 'ALTER TABLE wh_pallet_business_binding COMMENT = ''托盘业务绑定关系''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '仓库ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT ''仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='business_type' AND COALESCE(column_comment,'') <> '业务类型'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN business_type VARCHAR(32) NOT NULL COMMENT ''业务类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='business_id' AND COALESCE(column_comment,'') <> '绑定业务记录ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN business_id VARCHAR(128) NOT NULL COMMENT ''绑定业务记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND COALESCE(table_comment,'') <> '实物托盘状态幂等变更与补偿记录'), 'ALTER TABLE wh_pallet_operation COMMENT = ''实物托盘状态幂等变更与补偿记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘状态操作ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''托盘状态操作ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='operation_key' AND COALESCE(column_comment,'') <> '跨服务幂等操作键'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN operation_key VARCHAR(128) NOT NULL COMMENT ''跨服务幂等操作键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '状态变更请求摘要'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN request_hash VARCHAR(128) NOT NULL COMMENT ''状态变更请求摘要''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '实际海外仓ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT ''实际海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '实物托盘ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='pallet_code' AND COALESCE(column_comment,'') <> '实物托盘码快照'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''实物托盘码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_status' AND COALESCE(column_comment,'') <> '变更前托盘状态'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_status VARCHAR(20) NOT NULL COMMENT ''变更前托盘状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_status' AND COALESCE(column_comment,'') <> '变更后托盘状态'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_status VARCHAR(20) NOT NULL COMMENT ''变更后托盘状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_location_id' AND COALESCE(column_comment,'') <> '变更前库位ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_location_id BIGINT NOT NULL COMMENT ''变更前库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_location_id' AND COALESCE(column_comment,'') <> '变更后库位ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_location_id BIGINT NOT NULL COMMENT ''变更后库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_version' AND COALESCE(column_comment,'') <> '变更前托盘版本'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_version INT NOT NULL COMMENT ''变更前托盘版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_version' AND COALESCE(column_comment,'') <> '变更后托盘版本'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_version INT NOT NULL COMMENT ''变更后托盘版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='operation_status' AND COALESCE(column_comment,'') <> '操作状态:APPLIED已应用、COMPENSATED已补偿'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN operation_status VARCHAR(32) NOT NULL COMMENT ''操作状态:APPLIED已应用、COMPENSATED已补偿''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='reason' AND COALESCE(column_comment,'') <> '操作原因'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN reason VARCHAR(500) NULL COMMENT ''操作原因''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +-- ============================================================================ +-- 2026-08-19 / 026 +-- Separate tail-leg main workflow status from child-document status +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status')); +PREPARE ll_order_sub_status_026 FROM @ddl; EXECUTE ll_order_sub_status_026; DEALLOCATE PREPARE ll_order_sub_status_026; + +-- ============================================================================ +-- 2026-08-20 / 027 +-- One tail-leg inbound plan per source tail-leg order; one container may carry many plans. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NULL COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id')); +PREPARE ll_inbound_plan_source_order_027 FROM @ddl; EXECUTE ll_inbound_plan_source_order_027; DEALLOCATE PREPARE ll_inbound_plan_source_order_027; + +-- Legacy plans were created per container and cannot be inferred back to a source order. +UPDATE ll_inbound_plan SET source_order_id=NULL WHERE source_order_id=0; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='uk_ll_inbound_plan_container'), + 'ALTER TABLE ll_inbound_plan DROP INDEX uk_ll_inbound_plan_container', 'SELECT 1')); +PREPARE ll_inbound_plan_drop_container_unique_027 FROM @ddl; EXECUTE ll_inbound_plan_drop_container_unique_027; DEALLOCATE PREPARE ll_inbound_plan_drop_container_unique_027; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='uk_ll_inbound_plan_source_order'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan ADD UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id,source_order_id)')); +PREPARE ll_inbound_plan_source_order_unique_027 FROM @ddl; EXECUTE ll_inbound_plan_source_order_unique_027; DEALLOCATE PREPARE ll_inbound_plan_source_order_unique_027; + +-- ============================================================================ +-- 2026-08-20 / 028 +-- Phase-4 reverse event identity, unload evidence and SKU/history snapshots. +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), + 'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''', + 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; + +-- ============================================================================ +-- 2026-08-22 / 030 +-- Inventory balances aggregate packages by customer inbound and destination; +-- SKU remains a snapshot and no longer participates in the unique dimension. +-- Existing duplicate dimensions intentionally fail the unique-index rebuild so +-- operators can reconcile pallet and location balances before retrying. +-- ============================================================================ +SET @ddl = (SELECT CASE + WHEN EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension' AND column_name='sku_code') + THEN 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension, ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)' + WHEN NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension') + THEN 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)' + ELSE 'SELECT 1' END); +PREPARE phase4_inventory_dimension_030 FROM @ddl; EXECUTE phase4_inventory_dimension_030; DEALLOCATE PREPARE phase4_inventory_dimension_030; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_inventory_balance' AND column_name='sku_code'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''', + 'SELECT 1')); +PREPARE phase4_inventory_dimension_030 FROM @ddl; EXECUTE phase4_inventory_dimension_030; DEALLOCATE PREPARE phase4_inventory_dimension_030; +ALTER TABLE ll_inventory_balance COMMENT='客户入仓单与海外目的仓维度实时库存余额'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_local_looseboard_order' AND column_name='lastleg_order_id'), 'SELECT 1', + 'ALTER TABLE ll_local_looseboard_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''关联尾程主订单ID'' AFTER customer_id')); +PREPARE phase4_looseboard_tail_030 FROM @ddl; EXECUTE phase4_looseboard_tail_030; DEALLOCATE PREPARE phase4_looseboard_tail_030; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_local_looseboard_order' AND index_name='uk_ll_local_looseboard_lastleg'), 'SELECT 1', + 'ALTER TABLE ll_local_looseboard_order ADD UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id)')); +PREPARE phase4_looseboard_tail_030 FROM @ddl; EXECUTE phase4_looseboard_tail_030; DEALLOCATE PREPARE phase4_looseboard_tail_030; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_loading_order_status_history' AND column_name='customer_id'), 'SELECT 1', + 'ALTER TABLE ll_loading_order_status_history ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id')); +PREPARE phase4_loading_history_customer_030 FROM @ddl; EXECUTE phase4_loading_history_customer_030; DEALLOCATE PREPARE phase4_loading_history_customer_030; +UPDATE ll_loading_order_status_history history +JOIN ll_order orders ON orders.id=history.order_id +SET history.customer_id=orders.customer_id +WHERE history.customer_id IS NULL; +ALTER TABLE ll_loading_order_status_history + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_loading_order_status_history' AND index_name='idx_ll_loading_history_customer_order'), 'SELECT 1', + 'ALTER TABLE ll_loading_order_status_history ADD KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)')); +PREPARE phase4_loading_history_customer_030 FROM @ddl; EXECUTE phase4_loading_history_customer_030; DEALLOCATE PREPARE phase4_loading_history_customer_030; + +-- ============================================================================ +-- 2026-08-20 / 029 +-- FBA appointment expected loading time used by the platform appointment editor. +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='expected_loading_at'), + 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN expected_loading_at DATETIME(3) NOT NULL COMMENT ''预计装车时间''', + 'ALTER TABLE ll_inbound_fba_appointment ADD COLUMN expected_loading_at DATETIME(3) NULL COMMENT ''预计装车时间'' AFTER appointment_at')); +PREPARE phase4_fba_appointment_029 FROM @ddl; EXECUTE phase4_fba_appointment_029; DEALLOCATE PREPARE phase4_fba_appointment_029; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码;无SKU使用UNSPECIFIED''', + 'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码;无SKU使用UNSPECIFIED'' AFTER inbound_code')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照''', + 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_en'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照''', + 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; + +-- 旧版本库存余额使用 sku_name_zh;升级时保留名称快照并兼容新查询字段。 +SET @ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_name_zh') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'), + 'UPDATE ll_inventory_balance SET product_name_zh = COALESCE(NULLIF(product_name_zh, ''''), sku_name_zh) WHERE sku_name_zh IS NOT NULL', + 'SELECT 1')); +PREPARE phase4_inventory_legacy_name_028 FROM @ddl; EXECUTE phase4_inventory_legacy_name_028; DEALLOCATE PREPARE phase4_inventory_legacy_name_028; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), + 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照''', + 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照'' AFTER inbound_code')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_zh'), + 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照''', + 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_en'), + 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照''', + 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='operation_type'), + 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN operation_type VARCHAR(32) NOT NULL COMMENT ''操作类型:CREATE、REVIEW或UPDATE''', + 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT ''UPDATE'' COMMENT ''操作类型:CREATE、REVIEW或UPDATE'' AFTER customer_id')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='change_detail'), + 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON''', + 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON'' AFTER operator_id')); +PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028; +-- 四期货物废弃审核幂等快照;存量表按字段存在性幂等补齐。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'), + 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash')); +PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'), + 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); +PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs new file mode 100644 index 000000000..e794a23d3 --- /dev/null +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import test from 'node:test' + +const repositoryRoot = resolve(import.meta.dirname, '..') +const deployScript = resolve(repositoryRoot, 'deploy/scripts/deploy-built-service.sh') +const shell = process.platform === 'win32' + ? 'C:/Program Files/Git/bin/bash.exe' + : '/bin/sh' + +const mockDocker = `#!/usr/bin/env sh +set -eu +printf '%s\\n' "$*" >> "$MOCK_LOG" +command="$1" +shift +case "$command" in + login|logout|pull) + exit 0 + ;; + compose) + action='' + for argument in "$@"; do + case "$argument" in + ps|up) action="$argument" ;; + esac + done + case "$action" in + ps) + echo mock-container + ;; + up) + service='' + for argument in "$@"; do service="$argument"; done + if [ "$IMAGE_TAG" = "\${MOCK_FAIL_UP_TAG:-never}" ]; then + exit 3 + fi + printf '%s\\n' "$IMAGE_REPOSITORY/$service:$IMAGE_TAG" > "$MOCK_CURRENT_IMAGE" + if [ "$IMAGE_TAG" = "\${MOCK_FAIL_TAG:-never}" ]; then + printf '%s\\n' unhealthy > "$MOCK_HEALTH_STATE" + elif [ "$IMAGE_TAG" = "\${MOCK_RESTART_TAG:-never}" ]; then + 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' running > "$MOCK_RUNTIME_STATE" + fi + ;; + esac + ;; + inspect) + case "$2" in + *Config.Image*) cat "$MOCK_CURRENT_IMAGE" ;; + *State.Health*) cat "$MOCK_HEALTH_STATE" ;; + *State.Status*) cat "$MOCK_RUNTIME_STATE" ;; + *) exit 2 ;; + esac + ;; + logs) + echo 'mock startup failure' + ;; + *) + echo "unexpected docker command: $command $*" >&2 + exit 2 + ;; +esac +` + +async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag }) { + const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-')) + const mockBin = join(temporaryDirectory, 'bin') + const dockerPath = join(mockBin, 'docker') + const envFile = join(temporaryDirectory, 'deployment.env') + const logFile = join(temporaryDirectory, 'docker.log') + const currentImageFile = join(temporaryDirectory, 'current-image') + const healthStateFile = join(temporaryDirectory, 'health-state') + const runtimeStateFile = join(temporaryDirectory, 'runtime-state') + + await mkdir(mockBin) + await writeFile(dockerPath, mockDocker, 'utf8') + await chmod(dockerPath, 0o755) + await writeFile(envFile, 'PLACEHOLDER=true\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, [ + deployScript.replaceAll('\\', '/'), + 'development', + 'registry.local/group/firstleg', + imageTag, + 'audit-service', + envFile.replaceAll('\\', '/') + ], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${mockBin}${delimiter}${process.env.PATH ?? ''}`, + TARGET_IS_LOCAL: 'true', + REGISTRY_USER: 'ci-user', + REGISTRY_PASSWORD: 'ci-password', + DEPLOY_HEALTH_TIMEOUT_SECONDS: '2', + DEPLOY_HEALTH_POLL_SECONDS: '1', + MOCK_LOG: logFile, + MOCK_CURRENT_IMAGE: currentImageFile, + MOCK_HEALTH_STATE: healthStateFile, + MOCK_FAIL_TAG: failingTag ?? 'never', + MOCK_FAIL_UP_TAG: failingStartTag ?? 'never', + MOCK_RESTART_TAG: restartingTag ?? 'never', + MOCK_RUNTIME_STATE: runtimeStateFile + } + }) + + const dockerLog = await readFile(logFile, 'utf8') + const currentImage = (await readFile(currentImageFile, 'utf8')).trim() + await rm(temporaryDirectory, { recursive: true, force: true }) + return { ...result, dockerLog, currentImage } +} + +test('successful deployment keeps the new healthy image', async (context) => { + if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) + + const result = await runDeployment({ imageTag: 'newgood' }) + + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /部署前运行镜像: registry\.local\/group\/firstleg\/audit-service:oldgood/) + assert.match(result.stdout, /新镜像健康检查通过: registry\.local\/group\/firstleg\/audit-service:newgood/) + assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:newgood') +}) + +test('unhealthy deployment restores the previous healthy image and still fails the job', async (context) => { + if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) + + const result = await runDeployment({ imageTag: 'newbad', failingTag: 'newbad' }) + const output = `${result.stdout}\n${result.stderr}` + + assert.equal(result.status, 1, output) + assert.match(output, /新镜像健康检查失败: registry\.local\/group\/firstleg\/audit-service:newbad/) + assert.match(output, /新镜像容器最近日志(最多 200 行):/) + assert.match(output, /mock startup failure/) + assert.match(result.dockerLog, /logs --tail 200 mock-container/) + assert.match(output, /开始自动回滚到: registry\.local\/group\/firstleg\/audit-service:oldgood/) + assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) + assert.match(result.dockerLog, /pull registry\.local\/group\/firstleg\/audit-service:oldgood/) + assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:oldgood') +}) + +test('failed compose start also restores the previous healthy image', async (context) => { + if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) + + const result = await runDeployment({ imageTag: 'newbroken', failingStartTag: 'newbroken' }) + const output = `${result.stdout}\n${result.stderr}` + + assert.equal(result.status, 1, output) + assert.match(output, /新镜像启动命令失败: registry\.local\/group\/firstleg\/audit-service:newbroken/) + assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) + assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:oldgood') +}) + +test('restart loops fail fast and restore the previous image', async (context) => { + if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) + + const result = await runDeployment({ imageTag: 'restarting', restartingTag: 'restarting' }) + const output = `${result.stdout}\n${result.stderr}` + + assert.equal(result.status, 1, output) + assert.match(output, /容器未保持运行,立即停止等待: restarting/) + assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) + assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:oldgood') +}) diff --git a/scripts/finance-deployment-contract.test.mjs b/scripts/finance-deployment-contract.test.mjs index 7e20e5b37..87c520265 100644 --- a/scripts/finance-deployment-contract.test.mjs +++ b/scripts/finance-deployment-contract.test.mjs @@ -9,10 +9,10 @@ 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', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(controller, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(controller, /'finance': 'SERVICE_FINANCE'/) assert.match(controller, /booleanParam\(name: 'SERVICE_FINANCE'/) - assert.match(service, /'gateway', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(service, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(service, /'finance': 'finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar'/) }) diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index f694486e0..82eb5278e 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -110,7 +110,7 @@ test('default loading chain carries the three loading-list remarks contract', () assert.match(loading, /preservedRemarkItem\?\.warehouseRemark === warehouseRemark/) }) -test('default Admin HTTP entry carries evidence chain through settlement-aware PDA completion', () => { +test('default Admin HTTP entry completes settlement before customs consumes loaded slices', () => { const focused = normalizeModuleOrder([ 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', @@ -120,8 +120,8 @@ test('default Admin HTTP entry carries evidence chain through settlement-aware P assert.deepEqual(focused, [ 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', - 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-customs', - 'phase3-loading-finalize', + 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize', + 'phase3-customs', ]) const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8') @@ -146,10 +146,19 @@ test('default Admin HTTP entry carries evidence chain through settlement-aware P assert.ok(finalize.indexOf('const taskPath =') < finalize.indexOf('结算递增 inboundVersion 后 final complete 必须使用最新版本')) assert.match(finalize, /结算或版本冲突失败后 task\/plan\/inbound\/operation 全部零写入/) - assert.match(finalize, /结算递增版本后实际完成分片唯一且柜次身份稳定/) + assert.match(finalize, /结算后每个实装来源分片唯一且柜次身份稳定/) assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) }) +test('focused order splitting completes the initial loading settlement first', () => { + const focused = normalizeModuleOrder(['phase3-order-splitting']) + const loading = focused.indexOf('phase3-loading') + const finalize = focused.indexOf('phase3-loading-finalize') + const splitting = focused.indexOf('phase3-order-splitting') + + assert.ok(loading >= 0 && finalize > loading && splitting > finalize) +}) + test('Finance-only HTTP entry expands producer prerequisites and runs downloads last', () => { const focused = normalizeModuleOrder(['finance-downloads', 'finance-settlement-full']) assert.deepEqual(focused.slice(0, 4), [ diff --git a/scripts/generate-overseas-jwk.mjs b/scripts/generate-overseas-jwk.mjs new file mode 100644 index 000000000..c80f7f678 --- /dev/null +++ b/scripts/generate-overseas-jwk.mjs @@ -0,0 +1,36 @@ +import { generateKeyPairSync, randomUUID } from 'node:crypto' +import { readFileSync, writeFileSync } from 'node:fs' +import { basename, dirname, resolve } from 'node:path' + +const envPath = resolve(process.argv[2] || '') +if (!envPath || basename(dirname(envPath)) !== 'env' || !basename(envPath).startsWith('.env.')) { + throw new Error('用法: node scripts/generate-overseas-jwk.mjs deploy/env/.env.') +} + +let source = readFileSync(envPath, 'utf8') +const existing = source.match(/^OVERSEAS_JWT_RSA_JWK_SET=(.*)$/m)?.[1]?.trim() +if (existing) throw new Error('目标环境已经配置 OVERSEAS_JWT_RSA_JWK_SET,拒绝覆盖') + +const environment = basename(envPath).slice('.env.'.length).replace(/[^a-zA-Z0-9-]/g, '-') +const kid = `${environment}-overseas-${new Date().toISOString().slice(0, 10)}-${randomUUID().slice(0, 8)}` +const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 3072 }) +const privateJwk = privateKey.export({ format: 'jwk' }) +const jwkSet = JSON.stringify({ keys: [{ ...privateJwk, use: 'sig', alg: 'RS256', kid }] }) + +const values = new Map([ + ['OVERSEAS_JWT_ENABLED', 'true'], + ['OVERSEAS_JWT_ISSUER', 'auth-service'], + ['OVERSEAS_JWT_AUDIENCE', 'lastleg-api'], + ['OVERSEAS_JWT_RSA_JWK_SET', `'${jwkSet}'`], + ['OVERSEAS_JWT_CURRENT_KID', kid], + ['OVERSEAS_ACCESS_TOKEN_TTL', '15m'], +]) + +for (const [key, value] of values) { + const pattern = new RegExp(`^${key}=.*$`, 'm') + if (pattern.test(source)) source = source.replace(pattern, `${key}=${value}`) + else source += `${source.endsWith('\n') ? '' : '\n'}${key}=${value}\n` +} + +writeFileSync(envPath, source, 'utf8') +process.stdout.write(`Configured development-only overseas JWK kid: ${kid}\n`) diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json new file mode 100644 index 000000000..1ed545842 --- /dev/null +++ b/scripts/java-quality-baseline.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "mode": "report-only", + "tools": { + "jacocoMavenPlugin": "0.8.12", + "spotbugsMavenPlugin": "4.9.8.5" + }, + "topology": { + "modules": 22, + "codeModules": 21, + "services": 15 + }, + "openapi": { + "documents": 26, + "coveredServices": 7, + "codeFirstServices": 8 + }, + "reports": { + "jacocoPresent": 21, + "spotbugsPresent": 21, + "spotbugsFindings": 780, + "spotbugsByPriority": { + "1": 1, + "2": 779 + }, + "spotbugsByCategory": { + "BAD_PRACTICE": 10, + "CORRECTNESS": 3, + "EXPERIMENTAL": 4, + "MALICIOUS_CODE": 699, + "MT_CORRECTNESS": 2, + "PERFORMANCE": 18, + "STYLE": 44 + } + }, + "modules": [ + { "module": "common-core", "jacocoCovered": 32, "jacocoTotal": 78, "jacocoPercentage": 41.03, "spotbugsFindings": 6 }, + { "module": "common-web", "jacocoCovered": 164, "jacocoTotal": 188, "jacocoPercentage": 87.23, "spotbugsFindings": 1 }, + { "module": "common-security", "jacocoCovered": 109, "jacocoTotal": 202, "jacocoPercentage": 53.96, "spotbugsFindings": 6 }, + { "module": "common-openapi", "jacocoCovered": 17, "jacocoTotal": 37, "jacocoPercentage": 45.95, "spotbugsFindings": 0 }, + { "module": "common-audit", "jacocoCovered": 168, "jacocoTotal": 244, "jacocoPercentage": 68.85, "spotbugsFindings": 8 }, + { "module": "common-event-contract", "jacocoCovered": 146, "jacocoTotal": 164, "jacocoPercentage": 89.02, "spotbugsFindings": 18 }, + { "module": "gateway", "jacocoCovered": 110, "jacocoTotal": 156, "jacocoPercentage": 70.51, "spotbugsFindings": 4 }, + { "module": "gateway-overseas", "jacocoCovered": 134, "jacocoTotal": 272, "jacocoPercentage": 49.26, "spotbugsFindings": 2 }, + { "module": "auth", "jacocoCovered": 372, "jacocoTotal": 619, "jacocoPercentage": 60.10, "spotbugsFindings": 5 }, + { "module": "iam", "jacocoCovered": 1872, "jacocoTotal": 3032, "jacocoPercentage": 61.74, "spotbugsFindings": 60 }, + { "module": "order", "jacocoCovered": 1116, "jacocoTotal": 1605, "jacocoPercentage": 69.53, "spotbugsFindings": 47 }, + { "module": "firstleg", "jacocoCovered": 7891, "jacocoTotal": 11592, "jacocoPercentage": 68.07, "spotbugsFindings": 223 }, + { "module": "warehouse", "jacocoCovered": 1767, "jacocoTotal": 2922, "jacocoPercentage": 60.47, "spotbugsFindings": 64 }, + { "module": "warehouse-overseas-service", "jacocoCovered": 403, "jacocoTotal": 778, "jacocoPercentage": 51.80, "spotbugsFindings": 8 }, + { "module": "lastleg-service", "jacocoCovered": 5088, "jacocoTotal": 7832, "jacocoPercentage": 64.96, "spotbugsFindings": 162 }, + { "module": "file", "jacocoCovered": 1046, "jacocoTotal": 1614, "jacocoPercentage": 64.81, "spotbugsFindings": 33 }, + { "module": "print", "jacocoCovered": 596, "jacocoTotal": 695, "jacocoPercentage": 85.76, "spotbugsFindings": 12 }, + { "module": "audit-service", "jacocoCovered": 76, "jacocoTotal": 153, "jacocoPercentage": 49.67, "spotbugsFindings": 0 }, + { "module": "finance", "jacocoCovered": 1665, "jacocoTotal": 2236, "jacocoPercentage": 74.46, "spotbugsFindings": 97 }, + { "module": "integration-service", "jacocoCovered": 1040, "jacocoTotal": 1420, "jacocoPercentage": 73.24, "spotbugsFindings": 23 }, + { "module": "event-bridge-service", "jacocoCovered": 318, "jacocoTotal": 419, "jacocoPercentage": 75.89, "spotbugsFindings": 1 } + ] +} diff --git a/scripts/java-quality-summary.mjs b/scripts/java-quality-summary.mjs new file mode 100644 index 000000000..23c84d407 --- /dev/null +++ b/scripts/java-quality-summary.mjs @@ -0,0 +1,175 @@ +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +export const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) + +const normalize = (value) => value.replaceAll('\\', '/') +const attribute = (source, name) => source.match(new RegExp(`\\b${name}=(?:"([^"]*)"|'([^']*)')`))?.slice(1).find((value) => value !== undefined) + +async function exists(file) { + try { + await stat(file) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +async function javaSourceCount(moduleDirectory) { + const root = path.join(moduleDirectory, 'src', 'main', 'java') + if (!await exists(root)) return 0 + let count = 0 + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name) + if (entry.isDirectory()) await walk(target) + else if (entry.isFile() && entry.name.endsWith('.java')) count += 1 + } + } + await walk(root) + return count +} + +async function openApiFiles(moduleDirectory, module) { + const root = path.join(moduleDirectory, 'src', 'main', 'resources', 'openapi') + if (!await exists(root)) return [] + const files = [] + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name) + if (entry.isDirectory()) await walk(target) + else if (entry.isFile() && /\.(?:json|ya?ml)$/i.test(entry.name)) { + files.push(normalize(path.join(module, path.relative(moduleDirectory, target)))) + } + } + } + await walk(root) + return files.sort() +} + +export function parseModules(pom) { + const block = pom.match(/([\s\S]*?)<\/modules>/)?.[1] ?? '' + return [...block.matchAll(/([^<]+)<\/module>/g)].map((match) => match[1].trim()) +} + +export function parseServices(serviceMap) { + return [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map((match) => match[1]).sort() +} + +export function parseJacocoReport(xml) { + if (!//.test(xml)) throw new Error('not a JaCoCo report') + const counters = [...xml.matchAll(/]*)\/?\s*>/g)] + .map((match) => match[1]) + .filter((source) => attribute(source, 'type') === 'LINE') + if (counters.length === 0) throw new Error('JaCoCo LINE counter is missing') + const summary = counters.at(-1) + const missed = Number(attribute(summary, 'missed')) + const covered = Number(attribute(summary, 'covered')) + if (!Number.isInteger(missed) || !Number.isInteger(covered)) throw new Error('invalid JaCoCo LINE counter') + const total = missed + covered + return { missed, covered, percentage: total === 0 ? null : Number((covered * 100 / total).toFixed(2)) } +} + +export function parseSpotbugsReport(xml) { + if (!//.test(xml)) throw new Error('not a SpotBugs report') + const findings = [...xml.matchAll(/]*)>/g)].map((match) => match[1]) + const byPriority = {} + const byCategory = {} + for (const finding of findings) { + const priority = attribute(finding, 'priority') ?? 'unknown' + const category = attribute(finding, 'category') ?? 'unknown' + byPriority[priority] = (byPriority[priority] ?? 0) + 1 + byCategory[category] = (byCategory[category] ?? 0) + 1 + } + return { findings: findings.length, byPriority, byCategory } +} + +async function reportState(file, parser, root) { + const relative = normalize(path.relative(root, file)) + if (!await exists(file)) return { status: 'missing', path: relative } + try { + return { status: 'valid', path: relative, ...parser(await readFile(file, 'utf8')) } + } catch (error) { + return { status: 'invalid', path: relative, error: error instanceof Error ? error.message : String(error) } + } +} + +export async function buildQualitySummary(root = repositoryRoot) { + const modules = parseModules(await readFile(path.join(root, 'pom.xml'), 'utf8')) + const services = parseServices((await readFile(path.join(root, 'deploy', 'service-map.yml'), 'utf8')).replaceAll('\r\n', '\n')) + const moduleRows = [] + const documents = [] + for (const module of modules) { + const moduleDirectory = path.join(root, module) + const sources = await javaSourceCount(moduleDirectory) + documents.push(...await openApiFiles(moduleDirectory, module)) + if (sources === 0) continue + const jacoco = await reportState(path.join(moduleDirectory, 'target', 'site', 'jacoco', 'jacoco.xml'), parseJacocoReport, root) + const spotbugs = await reportState(path.join(moduleDirectory, 'target', 'spotbugsXml.xml'), parseSpotbugsReport, root) + spotbugs.html = await exists(path.join(moduleDirectory, 'target', 'site', 'spotbugs.html')) + moduleRows.push({ module, javaSources: sources, jacoco, spotbugs }) + } + const coveredServices = [...new Set(documents.map((file) => file.split('/')[0]))] + .filter((module) => services.includes(module)).sort() + const codeFirstServices = services.filter((service) => !coveredServices.includes(service)) + const count = (kind, status) => moduleRows.filter((row) => row[kind].status === status).length + return { + topology: { modules: modules.length, codeModules: moduleRows.length, services: services.length }, + openapi: { documents: documents.length, coveredServices, codeFirstServices, files: documents.sort() }, + jacoco: { + present: count('jacoco', 'valid'), invalid: count('jacoco', 'invalid'), missing: count('jacoco', 'missing') + }, + spotbugs: { + present: count('spotbugs', 'valid'), invalid: count('spotbugs', 'invalid'), + missing: count('spotbugs', 'missing'), findings: moduleRows.reduce((sum, row) => sum + (row.spotbugs.findings ?? 0), 0) + }, + modules: moduleRows + } +} + +export function requirementFailures(summary, options = {}) { + const failures = [] + for (const row of summary.modules) { + if (row.jacoco.status === 'invalid') failures.push(`${row.module}: invalid JaCoCo report (${row.jacoco.error})`) + if (row.spotbugs.status === 'invalid') failures.push(`${row.module}: invalid SpotBugs report (${row.spotbugs.error})`) + if (row.spotbugs.status === 'valid' && !row.spotbugs.html) failures.push(`${row.module}: SpotBugs HTML report is missing`) + if (options.requireJacoco && row.jacoco.status === 'missing') failures.push(`${row.module}: JaCoCo report is missing`) + if (options.requireSpotbugs && row.spotbugs.status === 'missing') failures.push(`${row.module}: SpotBugs report is missing`) + } + return failures +} + +function printSummary(summary) { + process.stdout.write(`Java quality summary: ${summary.topology.modules} modules, ${summary.topology.codeModules} code modules, ${summary.topology.services} services.\n`) + process.stdout.write(`OpenAPI: ${summary.openapi.documents} documents, ${summary.openapi.coveredServices.length}/${summary.topology.services} services; gaps: ${summary.openapi.codeFirstServices.join(', ')}.\n`) + process.stdout.write(`JaCoCo: ${summary.jacoco.present} valid, ${summary.jacoco.missing} missing, ${summary.jacoco.invalid} invalid.\n`) + process.stdout.write(`SpotBugs: ${summary.spotbugs.present} valid, ${summary.spotbugs.missing} missing, ${summary.spotbugs.invalid} invalid, ${summary.spotbugs.findings} findings.\n`) +} + +async function main() { + const args = process.argv.slice(2) + const rootIndex = args.indexOf('--root') + const outputIndex = args.indexOf('--output') + const root = rootIndex >= 0 ? path.resolve(args[rootIndex + 1]) : repositoryRoot + const summary = await buildQualitySummary(root) + printSummary(summary) + if (outputIndex >= 0) { + const output = path.resolve(args[outputIndex + 1]) + await mkdir(path.dirname(output), { recursive: true }) + await writeFile(output, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + } + const failures = requirementFailures(summary, { + requireJacoco: args.includes('--require-jacoco'), + requireSpotbugs: args.includes('--require-spotbugs') + }) + if (failures.length > 0) throw new Error(`Quality report contract failed:\n${failures.map((failure) => `- ${failure}`).join('\n')}`) +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs new file mode 100644 index 000000000..989dcfe43 --- /dev/null +++ b/scripts/java-quality-summary.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { + buildQualitySummary, + parseJacocoReport, + parseSpotbugsReport, + requirementFailures, + repositoryRoot +} from './java-quality-summary.mjs' + +test('parses JaCoCo line coverage and SpotBugs findings without quality thresholds', () => { + assert.deepEqual(parseJacocoReport(''), { + missed: 3, covered: 7, percentage: 70 + }) + assert.deepEqual(parseSpotbugsReport(''), { + findings: 2, + byPriority: { 1: 1, 2: 1 }, + byCategory: { CORRECTNESS: 1, STYLE: 1 } + }) + assert.deepEqual(parseSpotbugsReport(""), { + findings: 1, + byPriority: { 2: 1 }, + byCategory: { MALICIOUS_CODE: 1 } + }) + assert.throws(() => parseJacocoReport(''), /not a JaCoCo report/) + assert.throws(() => parseSpotbugsReport(''), /not a SpotBugs report/) +}) + +test('aggregates code modules, report presence and the OpenAPI service matrix', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'baiyue-quality-summary-')) + t.after(() => rm(root, { recursive: true, force: true })) + await writeFile(path.join(root, 'pom.xml'), 'bomalphabeta', 'utf8') + await mkdir(path.join(root, 'deploy'), { recursive: true }) + await writeFile(path.join(root, 'deploy', 'service-map.yml'), 'services:\n alpha:\n port: 1\n beta:\n port: 2\n', 'utf8') + await mkdir(path.join(root, 'alpha', 'src', 'main', 'java'), { recursive: true }) + await mkdir(path.join(root, 'beta', 'src', 'main', 'java'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'src', 'main', 'java', 'Alpha.java'), 'class Alpha {}', 'utf8') + await writeFile(path.join(root, 'beta', 'src', 'main', 'java', 'Beta.java'), 'class Beta {}', 'utf8') + await mkdir(path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi', 'alpha.yaml'), 'openapi: 3.0.3\n', 'utf8') + await mkdir(path.join(root, 'alpha', 'target', 'site', 'jacoco'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'target', 'site', 'jacoco', 'jacoco.xml'), '', 'utf8') + await mkdir(path.join(root, 'alpha', 'target', 'site'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'target', 'spotbugsXml.xml'), '', 'utf8') + await writeFile(path.join(root, 'alpha', 'target', 'site', 'spotbugs.html'), '', 'utf8') + + const summary = await buildQualitySummary(root) + assert.deepEqual(summary.topology, { modules: 3, codeModules: 2, services: 2 }) + assert.equal(summary.openapi.documents, 1) + assert.deepEqual(summary.openapi.coveredServices, ['alpha']) + assert.deepEqual(summary.openapi.codeFirstServices, ['beta']) + assert.deepEqual(summary.jacoco, { present: 1, invalid: 0, missing: 1 }) + assert.deepEqual(summary.spotbugs, { present: 1, invalid: 0, missing: 1, findings: 1 }) + assert.deepEqual(requirementFailures(summary), []) + assert.deepEqual(requirementFailures(summary, { requireJacoco: true, requireSpotbugs: true }), [ + 'beta: JaCoCo report is missing', 'beta: SpotBugs report is missing' + ]) +}) + +test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => { + const summary = await buildQualitySummary(repositoryRoot) + const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) + assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) + assert.equal(summary.openapi.documents, 26) + assert.equal(summary.openapi.coveredServices.length, 7) + assert.equal(summary.openapi.codeFirstServices.length, 8) + assert.equal(baseline.mode, 'report-only') + assert.deepEqual(baseline.topology, summary.topology) + assert.equal(baseline.modules.length, 21) + assert.equal(baseline.modules.reduce((total, row) => total + row.spotbugsFindings, 0), baseline.reports.spotbugsFindings) + + const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n') + const job = gitlab.match(/^质量摘要:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + assert.match(job, /^ allow_failure: true$/m) + assert.match(job, /java-quality-summary\.test\.mjs/) + assert.match(job, /java-quality-summary\.mjs --output target\/java-quality-summary\.json --require-jacoco --require-spotbugs/) + assert.match(job, /^ - job: 校验与测试$/m) + assert.match(job, /^ - job: 静态分析报告$/m) + assert.match(job, /^ - target\/java-quality-summary\.json$/m) + assert.doesNotMatch(job, /threshold|ratchet/i) + assert.doesNotMatch(packageJob, /^ - 质量摘要$/m) +}) diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs new file mode 100644 index 000000000..31f96cf24 --- /dev/null +++ b/scripts/java-topology-contract.test.mjs @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict' +import { access, 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') + +const sorted = (values) => [...values].sort() + +const parseRootModules = (xml) => { + const body = xml.match(/([\s\S]*?)<\/modules>/)?.[1] ?? '' + return [...body.matchAll(/([^<]+)<\/module>/g)].map((match) => match[1].trim()) +} + +const parsePom = (xml) => { + const project = xml.replace(/[\s\S]*?<\/parent>/, '') + return { + artifactId: project.match(/([^<]+)<\/artifactId>/)?.[1]?.trim(), + springBoot: /spring-boot-maven-plugin<\/artifactId>/.test(project) + } +} + +const parseServiceMap = (yaml) => { + const services = new Map() + let current + for (const line of yaml.split('\n')) { + const service = line.match(/^ ([a-z][a-z0-9-]+):$/) + if (service) { + current = {} + services.set(service[1], current) + continue + } + const property = line.match(/^ ([a-zA-Z]+): (.+)$/) + if (!current || !property) continue + const [, key, raw] = property + current[key] = key === 'port' + ? Number(raw) + : key === 'regions' + ? raw.slice(1, -1).split(',').map((value) => value.trim()) + : raw + } + return services +} + +const parseGroovyList = (source, variable) => { + const body = source.match(new RegExp(`def ${variable} = \\[([\\s\\S]*?)\\n\\]`))?.[1] ?? '' + return [...body.matchAll(/'([^']+)'/g)].map((match) => match[1]) +} + +const parseGroovyMap = (source, variable) => { + const body = source.match(new RegExp(`def ${variable} = \\[([\\s\\S]*?)\\n\\]`))?.[1] ?? '' + return new Map([...body.matchAll(/'([^']+)':\s*'([^']+)'/g)] + .map((match) => [match[1], match[2]])) +} + +const parseCompose = (yaml) => { + const body = yaml.match(/^services:\n([\s\S]*?)(?=^networks:)/m)?.[1] ?? '' + const matches = [...body.matchAll(/^ ([a-z][a-z0-9-]+):\s*$/gm)] + return new Map(matches.map((match, index) => { + const block = body.slice(match.index, matches[index + 1]?.index ?? body.length) + return [match[1], Number(block.match(/^ SERVER_PORT: (\d+)$/m)?.[1])] + })) +} + +test('root POM freezes 22 modules and service-map freezes 15 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) + + const bootModules = [] + for (const module of modules) { + const pom = parsePom(await read(`${module}/pom.xml`)) + if (pom.springBoot) bootModules.push(module) + if (!services.has(module)) continue + const service = services.get(module) + assert.equal(service.artifactId, pom.artifactId, `${module} artifactId drifted`) + assert.equal(service.jar, `${module}/target/${pom.artifactId}-1.0.0-SNAPSHOT.jar`) + } + assert.deepEqual(sorted(bootModules), sorted(services.keys())) + assert.equal(new Set([...services.values()].map(({ port }) => port)).size, services.size) +}) + +test('Jenkins and GitLab package exactly the service-map modules', async () => { + const services = parseServiceMap(await read('deploy/service-map.yml')) + const expected = sorted(services.keys()) + const coordinator = await read('Jenkinsfile') + const servicePipeline = await read('Jenkinsfile.service') + const serviceJars = parseGroovyMap(servicePipeline, 'serviceJars') + const gitlab = await read('.gitlab-ci.yml') + const gitlabArtifacts = [...gitlab.matchAll(/^ - ([a-z][a-z0-9-]+)\/target\/\*\.jar$/gm)] + .map((match) => match[1]) + + assert.deepEqual(sorted(parseGroovyList(coordinator, 'serviceModules')), expected) + assert.deepEqual(sorted(parseGroovyList(servicePipeline, 'serviceModules')), expected) + assert.deepEqual(sorted(serviceJars.keys()), expected) + assert.deepEqual(sorted(gitlabArtifacts), expected) + for (const [module, service] of services) assert.equal(serviceJars.get(module), service.jar) +}) + +test('GitLab runs the topology contract in a dedicated Node job', async () => { + const gitlab = await read('.gitlab-ci.yml') + const topologyJob = gitlab.match(/^Java拓扑合同:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + + assert.match(topologyJob, /^ stage: verify$/m) + assert.match(topologyJob, /^ image: node:[^\s]+$/m) + assert.match(topologyJob, /^ - node --test scripts\/java-topology-contract\.test\.mjs$/m) + assert.match(packageJob, /^ - Java拓扑合同$/m) +}) + +test('GitLab validates every static OpenAPI document before packaging', async () => { + const gitlab = await read('.gitlab-ci.yml') + const openapiJob = gitlab.match(/^OpenAPI有效性:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + + assert.match(openapiJob, /^ stage: verify$/m) + assert.match(openapiJob, /^ image: node:[^\s]+$/m) + assert.match(openapiJob, /^ - npm ci --ignore-scripts --prefix scripts\/openapi-validation$/m) + assert.match(openapiJob, /^ - npm test --prefix scripts\/openapi-validation$/m) + assert.match(openapiJob, /^ - npm run validate --prefix scripts\/openapi-validation$/m) + assert.doesNotMatch(openapiJob, /\bnpx\b|@latest/) + assert.match(packageJob, /^ - Java拓扑合同$/m) + assert.match(packageJob, /^ - OpenAPI有效性$/m) + assert.match(packageJob, /^ - 校验与测试$/m) +}) + +test('Jenkins cannot invoke the Node contract without declaring a Node runtime', async () => { + for (const path of ['Jenkinsfile', 'Jenkinsfile.service']) { + const pipeline = await read(path) + if (!pipeline.includes('java-topology-contract.test.mjs')) continue + assert.match( + pipeline, + /(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/, + `${path} must declare how Node is provided before invoking the topology contract` + ) + } +}) + +test('Maven verify publishes JaCoCo reports without enforcing a threshold', async () => { + const pom = await read('pom.xml') + const gitlab = await read('.gitlab-ci.yml') + const jacoco = pom.match(/\s*org\.jacoco<\/groupId>[\s\S]*?<\/plugin>/)?.[0] ?? '' + + assert.match(jacoco, /jacoco-maven-plugin<\/artifactId>/) + assert.match(jacoco, /prepare-agent<\/goal>/) + assert.match(jacoco, /verify<\/phase>/) + assert.match(jacoco, /report<\/goal>/) + assert.match(jacoco, /HTML<\/format>/) + assert.match(jacoco, /XML<\/format>/) + assert.doesNotMatch(jacoco, /check<\/goal>|/) + assert.match(gitlab, /^ - "\*\*\/target\/site\/jacoco\/"$/m) + assert.match(gitlab, /^ - "\*\*\/target\/jacoco\.exec"$/m) +}) + +test('SpotBugs remains an opt-in non-blocking report without quality thresholds', async () => { + const pom = await read('pom.xml') + const gitlab = await read('.gitlab-ci.yml') + const profile = pom.match(/\s*static-analysis-report<\/id>[\s\S]*?<\/profile>/)?.[0] ?? '' + const spotbugs = profile.match(/\s*com\.github\.spotbugs<\/groupId>[\s\S]*?<\/plugin>/)?.[0] ?? '' + const reportJob = gitlab.match(/^静态分析报告:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const verifyJob = gitlab.match(/^校验与测试:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + + assert.match(pom, /4\.9\.8\.5<\/spotbugs-maven-plugin\.version>/) + assert.match(spotbugs, /spotbugs-maven-plugin<\/artifactId>/) + assert.match(spotbugs, /verify<\/phase>/) + assert.match(spotbugs, /spotbugs<\/goal>/) + assert.match(spotbugs, /true<\/xmlOutput>/) + assert.match(spotbugs, /true<\/htmlOutput>/) + assert.match(spotbugs, /false<\/failOnError>/) + assert.doesNotMatch(spotbugs, /check<\/goal>||/) + assert.doesNotMatch(pom.replace(profile, ''), /spotbugs-maven-plugin<\/artifactId>/) + assert.match(reportJob, /^ allow_failure: true$/m) + assert.match(reportJob, /-Pstatic-analysis-report/) + assert.match(reportJob, /^ - "\*\*\/target\/spotbugs\*\.xml"$/m) + assert.match(reportJob, /^ - "\*\*\/target\/site\/spotbugs\.html"$/m) + assert.doesNotMatch(verifyJob, /static-analysis-report|spotbugs/i) + assert.doesNotMatch(packageJob, /^ - 静态分析报告$/m) +}) + +test('regional Compose and Nacos manifests cover the service-map topology', 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'))] + ]) + + 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.deepEqual( + sorted(new Set([...composeByRegion.values()].flatMap((compose) => [...compose.keys()]))), + sorted(services.keys()) + ) +}) diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index 1eff76bdd..733ad8e4a 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop' $Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Workspace = (Resolve-Path (Join-Path $Repo '..')).Path $Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance' -$ExpectedSha256 = '00E8F9C24CE16FE6B4D2B6AF60460AE1AF1D264DFE110D4BDCE611F09EB69EAA' -$ExpectedLines = 15403 +$ExpectedSha256 = '0EE13427AB60CECCEB42985CF1E78A05645C18CD9BF2886110C0AFEAF3CA7B9B' +$ExpectedLines = 15674 if ($TargetHost -notin @('127.0.0.1', 'localhost')) { [Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.') diff --git a/scripts/openapi-validation/package-lock.json b/scripts/openapi-validation/package-lock.json new file mode 100644 index 000000000..a1127021a --- /dev/null +++ b/scripts/openapi-validation/package-lock.json @@ -0,0 +1,180 @@ +{ + "name": "baiyue-openapi-validation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "baiyue-openapi-validation", + "version": "1.0.0", + "dependencies": { + "@apidevtools/swagger-parser": "12.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/scripts/openapi-validation/package.json b/scripts/openapi-validation/package.json new file mode 100644 index 000000000..b270dd57e --- /dev/null +++ b/scripts/openapi-validation/package.json @@ -0,0 +1,16 @@ +{ + "name": "baiyue-openapi-validation", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "test": "node --test validate-openapi.test.mjs", + "validate": "node validate-openapi.mjs" + }, + "dependencies": { + "@apidevtools/swagger-parser": "12.1.0" + } +} diff --git a/scripts/openapi-validation/validate-openapi.mjs b/scripts/openapi-validation/validate-openapi.mjs new file mode 100644 index 000000000..16d47d6ed --- /dev/null +++ b/scripts/openapi-validation/validate-openapi.mjs @@ -0,0 +1,83 @@ +import SwaggerParser from '@apidevtools/swagger-parser' +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const OPENAPI_DIRECTORY = ['src', 'main', 'resources', 'openapi'].join('/') +const OPENAPI_EXTENSIONS = new Set(['.json', '.yaml', '.yml']) +const EXCLUDED_DIRECTORIES = new Set([ + '.git', '.logs', 'dist', 'node_modules', 'output', 'outputs', 'reports', 'target' +]) + +export const repositoryRoot = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) + +export async function discoverOpenApiFiles(root) { + const files = [] + async function walk(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory() && EXCLUDED_DIRECTORIES.has(entry.name)) continue + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) { + await walk(absolute) + continue + } + const relative = path.relative(root, absolute).replaceAll(path.sep, '/') + if (relative.includes(`/${OPENAPI_DIRECTORY}/`) + && OPENAPI_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(relative) + } + } + await walk(root) + return files.sort() +} + +export function parseServiceModules(serviceMap) { + return [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map((match) => match[1]).sort() +} + +export function summarizeCoverage(files, serviceModules) { + const coveredServices = [...new Set(files.map((file) => file.split('/')[0]))].sort() + const codeFirstServices = serviceModules.filter((service) => !coveredServices.includes(service)) + return { coveredServices, codeFirstServices } +} + +export async function validateRepositoryOpenApi(root = repositoryRoot) { + const files = await discoverOpenApiFiles(root) + if (files.length === 0) throw new Error('No static OpenAPI documents were discovered') + const serviceMap = await readFile(path.join(root, 'deploy', 'service-map.yml'), 'utf8') + const serviceModules = parseServiceModules(serviceMap.replaceAll('\r\n', '\n')) + const coverage = summarizeCoverage(files, serviceModules) + + const failures = [] + for (const relative of files) { + try { + await SwaggerParser.validate(path.join(root, relative)) + } catch (error) { + failures.push(`${relative}: ${error instanceof Error ? error.message : String(error)}`) + } + } + if (failures.length > 0) { + throw new Error( + `Discovered ${files.length} documents across ${coverage.coveredServices.length}/${serviceModules.length} services; ` + + `code-first gaps: ${coverage.codeFirstServices.join(', ')}.\n` + + `OpenAPI validation failed for ${failures.length}/${files.length} documents:\n` + + failures.map((failure) => `- ${failure}`).join('\n') + ) + } + + return { files, serviceModules, ...coverage } +} + +async function main() { + const result = await validateRepositoryOpenApi() + process.stdout.write(`OpenAPI validation passed: ${result.files.length} documents across ${result.coveredServices.length}/${result.serviceModules.length} services.\n`) + process.stdout.write(`Static OpenAPI services: ${result.coveredServices.join(', ')}\n`) + process.stdout.write(`Code-first services without static documents: ${result.codeFirstServices.join(', ')}\n`) +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/openapi-validation/validate-openapi.test.mjs b/scripts/openapi-validation/validate-openapi.test.mjs new file mode 100644 index 000000000..4c2eaeb24 --- /dev/null +++ b/scripts/openapi-validation/validate-openapi.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { + discoverOpenApiFiles, + parseServiceModules, + summarizeCoverage, + validateRepositoryOpenApi +} from './validate-openapi.mjs' + +test('discovers new static OpenAPI documents without a handwritten file list', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'baiyue-openapi-discovery-')) + t.after(() => rm(root, { recursive: true, force: true })) + const openapi = path.join(root, 'new-service', 'src', 'main', 'resources', 'openapi') + const ignored = path.join(root, 'new-service', 'target', 'src', 'main', 'resources', 'openapi') + await mkdir(openapi, { recursive: true }) + await mkdir(ignored, { recursive: true }) + await writeFile(path.join(openapi, 'new-api.yaml'), 'openapi: 3.0.3\n', 'utf8') + await writeFile(path.join(openapi, 'new-api.json'), '{"openapi":"3.0.3"}\n', 'utf8') + await writeFile(path.join(openapi, 'notes.md'), 'not an OpenAPI document\n', 'utf8') + await writeFile(path.join(ignored, 'ignored.yaml'), 'openapi: 3.0.3\n', 'utf8') + + assert.deepEqual(await discoverOpenApiFiles(root), [ + 'new-service/src/main/resources/openapi/new-api.json', + 'new-service/src/main/resources/openapi/new-api.yaml' + ]) +}) + +test('derives static and code-first service coverage from service-map', () => { + const serviceModules = parseServiceModules('services:\n alpha:\n port: 1\n beta:\n port: 2\n') + assert.deepEqual(serviceModules, ['alpha', 'beta']) + assert.deepEqual(summarizeCoverage([ + 'alpha/src/main/resources/openapi/alpha.yaml' + ], serviceModules), { + coveredServices: ['alpha'], + codeFirstServices: ['beta'] + }) +}) + +test('rejects an unresolved OpenAPI reference', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'baiyue-openapi-ref-')) + t.after(() => rm(root, { recursive: true, force: true })) + const openapi = path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi') + await mkdir(openapi, { recursive: true }) + await mkdir(path.join(root, 'deploy'), { recursive: true }) + await writeFile(path.join(root, 'deploy', 'service-map.yml'), 'services:\n alpha:\n port: 1\n', 'utf8') + await writeFile(path.join(openapi, 'broken.yaml'), `openapi: 3.0.3 +info: + title: Broken API + version: 1.0.0 +paths: + /broken: + get: + responses: + '200': + description: broken + content: + application/json: + schema: + $ref: '#/components/schemas/Missing' +`, 'utf8') + + await assert.rejects(validateRepositoryOpenApi(root), /Missing \$ref pointer/) +}) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs new file mode 100644 index 000000000..72155a2ea --- /dev/null +++ b/scripts/overseas-deployment-contract.test.mjs @@ -0,0 +1,151 @@ +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('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.match(controller, /事件桥 BOTH 仅支持开发或测试环境/) +}) + +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_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_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.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(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, /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', '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('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') +}) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs new file mode 100644 index 000000000..11b3dfb67 --- /dev/null +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -0,0 +1,128 @@ +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' + +const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8') + +test('phase-four HTTP acceptance modules are registered in the Admin acceptance entry', () => { + for (const module of [ + 'phase4-overseas-warehouse', + 'phase4-overseas-pda', + 'phase4-overseas-pda-operations', + 'phase4-overseas-inbound-operations', + 'phase4-overseas-client-services', + 'phase4-overseas-dispatch', + 'phase4-cross-region-events', + 'phase4-overseas-business', + 'phase4-overseas-master-data', + 'phase4-carrier-nodes', + ]) { + assert.match(source, new RegExp(`CONFIGURED_MODULES\\s*=\\s*\\[[^\\]]*'${module}'`, 's')) + } +}) + +test('overseas inbound commands, client services and dispatch follow completed PDA receiving', () => { + for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', + 'phase4-overseas-dispatch']) { + const modules = parseArguments(['--modules', consumer]).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', + 'phase3-loading-finalize', 'phase4-overseas-pda', 'phase4-overseas-pda-operations']) { + assert.ok(modules.indexOf(prerequisite) >= 0 + && modules.indexOf(prerequisite) < modules.indexOf(consumer)) + } + } +}) + +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', + ]) + 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\]/) +}) + +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', + username: 'contract-user', password: 'contract-password', modules: [], output: '' }) + Object.assign(run, { token: 'old-access', refreshToken: 'old-refresh', overseasToken: 'old-overseas', overseasExpiresAt: 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, + } }, 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()) + + const frozen = { token: run.token, refreshToken: run.refreshToken, + overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt } + 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) +}) + +test('entry has no built-in environment address or password and requires explicit execution options', () => { + assert.doesNotMatch(source, /8\.163\.|ChangeMe123|DEFAULT_PASSWORD|DEFAULT_GATEWAY/) + assert.throws(() => validateExecutionOptions(parseArguments(['--modules', 'phase4-overseas-pda'])), + /缺少必填执行参数/) + const options = parseArguments([ + '--gateway', 'http://localhost:9000', '--overseas-gateway', 'http://localhost:9300', + '--username', 'contract-user', '--password', 'contract-password', '--modules', 'phase4-overseas-pda', + ]) + assert.equal(validateExecutionOptions(options), options) +}) + +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', + username: 'contract-user', password: 'contract-password', modules: [], output: '' }) + run.addCoverageGap('phase4', 'core', 'fixture', 'missing fixture', 'fixture') + assert.equal(run.steps.at(-1)?.passed, false) + assert.equal(run.steps.at(-1)?.deploymentBlocker, true) + const stepCount = run.steps.length + run.addCoverageGap('phase3', 'device', 'Android', 'manual device validation', 'device') + run.addCoverageGap('phase3', 'provider', 'captcha', 'external captcha', 'external-third-party') + assert.equal(run.steps.length, stepCount) +}) + +test('PDA entry expands the real phase-three producer chain and builds an FBA/OVERSEAS/second-container matrix', () => { + const modules = parseArguments(['--modules', 'phase4-overseas-pda']).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence', + 'phase3-evidence-gate', 'phase3-loading-finalize']) { + assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-pda')) + } + assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/) + assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) + assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/) + assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/) + assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) + assert.match(source, /runUntilPickupReady\(this\)/) + assert.match(source, /carrierScheduleQueryId/) + assert.match(source, /readyForPhase4Pda === true/) + assert.equal(modules.indexOf('phase4-overseas-pda-operations'), modules.indexOf('phase4-overseas-pda') + 1) +}) diff --git a/scripts/provision-development-service-jobs.groovy b/scripts/provision-development-service-jobs.groovy new file mode 100644 index 000000000..e4c7ced35 --- /dev/null +++ b/scripts/provision-development-service-jobs.groovy @@ -0,0 +1,38 @@ +import jenkins.model.Jenkins +import org.jenkinsci.plugins.workflow.job.WorkflowJob + +// 首次在 Jenkins Script Console 执行时保持 false,只输出计划;人工核对后再改为 true。 +boolean applyChanges = false +String templateName = 'baiyue-firstleg-dev-print' +List serviceModules = [ + 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' +] + +Jenkins jenkins = Jenkins.get() +List templates = jenkins.getAllItems(WorkflowJob).findAll { it.name == templateName } +if (templates.size() != 1) { + throw new IllegalStateException("必须且只能找到一个模板 Job ${templateName},当前数量: ${templates.size()}") +} + +WorkflowJob template = templates.first() +def parent = template.parent +println "模板 Job: ${template.fullName}" +println "目标位置: ${parent.fullName ?: '/'}" + +serviceModules.each { service -> + String jobName = "baiyue-firstleg-dev-${service}" + def existing = parent.getItem(jobName) + if (existing != null) { + println "保留已有 Job: ${existing.fullName}" + } else if (applyChanges) { + def created = parent.copy(template, jobName) + created.description = "${service} 服务开发环境独立构建与部署" + created.save() + println "已创建 Job: ${created.fullName}" + } else { + println "计划创建 Job: ${parent.fullName ? parent.fullName + '/' : ''}${jobName}" + } +} + +println applyChanges ? '执行完成' : '当前为预览模式,未修改 Jenkins' diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs new file mode 100644 index 000000000..aba1424d1 --- /dev/null +++ b/scripts/synchronous-dependency-baseline.mjs @@ -0,0 +1,235 @@ +export const CONTEXTS = Object.freeze(['REQUEST', 'BACKGROUND', 'INFRASTRUCTURE', 'EXTERNAL']) +export const ACCESS_MODES = Object.freeze(['READ', 'WRITE', 'READ_WRITE']) +export const IDEMPOTENCY = Object.freeze([ + 'SAFE_READ', 'IDEMPOTENCY_KEY', 'BUSINESS_KEY', 'EVENT_ID', 'STATE_CONVERGENT', 'NONE', 'MIXED' +]) + +const feign = (path, source, target, contexts, access, idempotency, note) => ({ + id: `FEIGN:${path}`, + transport: 'FEIGN', path, source, target, contexts, access, idempotency, note +}) + +const http = (path, source, target, contexts, access, idempotency, note) => ({ + id: `HTTP:${path}`, + transport: 'HTTP', path, source, target, contexts, access, idempotency, note +}) + +export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java', + 'auth-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 读取授权快照,不写 IAM'), + feign('auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java', + 'auth-service', 'iam-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '注册/补偿以 authUserId 收敛,不自动重试'), + + feign('file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java', + 'file-service', 'finance-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java', + 'file-service', 'firstleg-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java', + 'file-service', 'iam-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/IamQualificationClient.java', + 'file-service', 'iam-service', ['REQUEST'], 'WRITE', 'STATE_CONVERGENT', '资质登记/替换按客户与资质类型收敛,无通用幂等头'), + feign('file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java', + 'file-service', 'order-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java', + 'file-service', 'warehouse-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/WeightOcrClient.java', + 'file-service', 'integration-service', ['REQUEST'], 'READ', 'NONE', '上传线程内调用 OCR;第三方调用不可假定免费幂等'), + + feign('finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java', + 'finance-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '元数据 GET 可重复,凭证上传无幂等键'), + feign('finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java', + 'finance-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取整柜成本分摊快照'), + feign('finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java', + 'finance-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'MIXED', + '事件消费回读快照;平台作废命令携带 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/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/FeePaymentVoucherFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '校验付款凭证元数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/FinanceFeeSourceEventClient.java', + 'firstleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用 Outbox 按事件幂等键投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java', + 'firstleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户名称与状态快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据/下载可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取装柜证据元数据/内容'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '图片上传/作废非安全重放,查询可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java', + 'firstleg-service', 'order-service', ['REQUEST', 'BACKGROUND'], 'READ', 'SAFE_READ', + '在线测量/箱号/排柜读取;Order 审核 Outbox 创建 Firstleg 入库及箱号时也会后台回读订单货物'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java', + 'firstleg-service', 'order-service', ['REQUEST'], 'WRITE', 'IDEMPOTENCY_KEY', 'PDA 结算拆单携带 idempotencyKey/operationId'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '价目文件上传无幂等键,读取可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库/渠道主数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java', + 'firstleg-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '无状态 PDF 渲染,POST 为计算型读取'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '附件上传无幂等键,元数据读取可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库主数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '查询可重复;预占/选择释放/转移/结算使用业务幂等键,普通确认/释放按状态收敛'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取供应商快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/customs/client/CustomsFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '报关文件上传无幂等键,读取可重复'), + + feign('iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java', + 'iam-service', 'auth-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '账号查询可重复,写操作按账号/命令业务键收敛'), + feign('iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'iam-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'BUSINESS_KEY', + '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/masterdata/quote/client/DomesticPartnerWarehouseClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '报价创建时读取国内合作仓快照'), + feign('warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/DomesticWarehouseDirectoryClient.java', + 'warehouse-overseas-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '海外库位、托盘和资源锁写入前读取国内共享收货仓权威目录'), + + feign('order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java', + 'order-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'BUSINESS_KEY', + '列表查询/缺失修复在线执行;审核建单由 Outbox 投递,按 orderId/inboundCode 收敛'), + feign('order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 校验计价渠道,不写履约状态'), + feign('order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 查询统一订单投影'), + feign('order/src/main/java/com/baiyue/order/client/ProductImageClient.java', + 'order-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取商品图片元数据'), + feign('order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java', + 'order-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户下单资格'), + feign('order/src/main/java/com/baiyue/order/client/WarehouseClient.java', + 'order-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库主数据'), + + 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/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/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', + 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '查询可重复;写操作按 business/reservation key 或状态收敛'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', + 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'IDEMPOTENCY_KEY', '托盘变更/补偿使用 operationKey 与请求哈希'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), + http('integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', + 'integration-service', 'external-evergreen', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java', + 'integration-service', 'external-hede', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java', + 'integration-service', 'external-matson', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java', + 'integration-service', 'external-sml', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java', + 'integration-service', 'external-zim', ['EXTERNAL'], 'READ', 'SAFE_READ', '令牌获取与轨迹查询不改变平台业务事实'), + http('integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java', + 'integration-service', 'external-baidu-ocr', ['EXTERNAL'], 'READ', 'NONE', 'OCR 可能计费,不由 HTTP 层自动重试') +]) + +export const USER_ENTRIES = Object.freeze([ + { name: '认证 API', service: 'auth-service' }, + { name: 'IAM API', service: 'iam-service' }, + { name: '订单 API', service: 'order-service' }, + { name: '头程 API/PDA', service: 'firstleg-service' }, + { name: '财务 API', service: 'finance-service' }, + { name: '文件 API', service: 'file-service' }, + { name: '海外尾程 API/PDA', service: 'lastleg-service' }, + { name: '国内仓储 API', service: 'warehouse-service' }, + { name: '海外仓储 API', service: 'warehouse-overseas-service' } +]) + +export function graphEdges(context) { + return [...new Set(SYNCHRONOUS_CLIENTS + .filter(client => context === 'ALL' || client.contexts.includes(context)) + .map(client => `${client.source}->${client.target}`))].sort() +} + +export function stronglyConnectedComponents(edgeNames) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + if (!adjacency.has(to)) adjacency.set(to, new Set()) + adjacency.get(from).add(to) + } + let index = 0 + const indexes = new Map() + const lowLinks = new Map() + const stack = [] + const onStack = new Set() + const components = [] + function connect(node) { + indexes.set(node, index) + lowLinks.set(node, index++) + stack.push(node) + onStack.add(node) + for (const target of adjacency.get(node)) { + if (!indexes.has(target)) { + connect(target) + lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) + } else if (onStack.has(target)) lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) + } + if (lowLinks.get(node) !== indexes.get(node)) return + const component = [] + let current + do { + current = stack.pop() + onStack.delete(current) + component.push(current) + } while (current !== node) + components.push(component.sort()) + } + for (const node of adjacency.keys()) if (!indexes.has(node)) connect(node) + return components.filter(component => component.length > 1) + .sort((left, right) => left.join().localeCompare(right.join())) +} + +export function longestSimplePaths(entryService, edgeNames = graphEdges('REQUEST')) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + adjacency.get(from).add(to) + } + let longest = [] + function visit(node, path) { + const targets = [...(adjacency.get(node) ?? [])].sort() + let extended = false + for (const target of targets) { + if (path.includes(target)) continue + extended = true + visit(target, [...path, target]) + } + if (extended) return + if (longest.length === 0 || path.length > longest[0].length) longest = [path] + else if (path.length === longest[0].length) longest.push(path) + } + visit(entryService, [entryService]) + return longest.sort((left, right) => left.join('->').localeCompare(right.join('->'))) +} diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs new file mode 100644 index 000000000..7f00cb335 --- /dev/null +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -0,0 +1,280 @@ +import assert from 'node:assert/strict' +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { + ACCESS_MODES, CONTEXTS, IDEMPOTENCY, SYNCHRONOUS_CLIENTS, USER_ENTRIES, + graphEdges, longestSimplePaths, stronglyConnectedComponents +} from './synchronous-dependency-baseline.mjs' + +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' +] + +const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) + +const expectedOnlineEdges = [ + 'auth-service->iam-service', + 'file-service->iam-service', + 'file-service->integration-service', + 'finance-service->file-service', + 'finance-service->firstleg-service', + 'finance-service->iam-service', + 'finance-service->warehouse-service', + 'firstleg-service->file-service', + 'firstleg-service->iam-service', + 'firstleg-service->order-service', + 'firstleg-service->print-service', + 'firstleg-service->warehouse-service', + 'iam-service->auth-service', + 'iam-service->warehouse-service', + 'lastleg-service->file-service', + 'lastleg-service->warehouse-overseas-service', + 'lastleg-service->warehouse-service', + 'order-service->file-service', + 'order-service->firstleg-service', + 'order-service->iam-service', + 'order-service->warehouse-service', + 'warehouse-overseas-service->warehouse-service' +] + +const expectedBackgroundEdges = [ + '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->firstleg-service', + 'firstleg-service->event-bridge-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', + 'order-service->firstleg-service' +] + +const rawHttpPattern = /\b(?:RestClient|WebClient|HttpClient)\b/ + +async function javaFiles(directory) { + const result = [] + async function visit(current) { + for (const entry of await readdir(current, { withFileTypes: true })) { + const target = path.join(current, entry.name) + if (entry.isDirectory()) await visit(target) + else if (entry.isFile() && entry.name.endsWith('.java')) result.push(target) + } + } + await visit(directory) + return result +} + +async function discoverFeignEdges() { + const clients = [] + for (const module of serviceModules) { + const sourceRoot = path.join(root, module, 'src', 'main', 'java') + for (const file of await javaFiles(sourceRoot)) { + const source = await readFile(file, 'utf8') + if (!source.includes('@FeignClient')) continue + const annotation = source.match(/@FeignClient\(([\s\S]*?)\)\s*(?:public\s+)?interface/) + const target = annotation?.[1].match(/name\s*=\s*"([^"]+)"/)?.[1] + assert.ok(target, `cannot resolve Feign target from ${path.relative(root, file)}`) + clients.push({ + path: path.relative(root, file).replaceAll('\\', '/'), + source: canonicalSource.get(module), target + }) + } + } + return clients.sort((left, right) => left.path.localeCompare(right.path)) +} + +test('all 45 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, 45) + assert.deepEqual(await discoverFeignEdges(), reviewed) +}) + +test('all 16 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 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 + 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, 16) + assert.deepEqual(discovered.sort(), reviewed) +}) + +test('every reviewed synchronous client has a complete and exclusive executable classification', () => { + assert.equal(new Set(SYNCHRONOUS_CLIENTS.map(client => client.id)).size, SYNCHRONOUS_CLIENTS.length) + for (const client of SYNCHRONOUS_CLIENTS) { + assert.ok(client.path && client.source && client.target && client.note, `${client.id} metadata is incomplete`) + assert.ok(client.contexts.length > 0, `${client.id} has no execution context`) + assert.equal(new Set(client.contexts).size, client.contexts.length, `${client.id} repeats a context`) + for (const context of client.contexts) assert.ok(CONTEXTS.includes(context), `${client.id} has invalid context ${context}`) + assert.ok(ACCESS_MODES.includes(client.access), `${client.id} has invalid access ${client.access}`) + assert.ok(IDEMPOTENCY.includes(client.idempotency), `${client.id} has invalid idempotency ${client.idempotency}`) + } +}) + +test('IAM customer dimension Feign is reachable only from the background relay', async () => { + const references = [] + for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) { + const source = await readFile(file, 'utf8') + if (source.includes('CustomerDimensionRuleClient')) { + references.push(path.relative(root, file).replaceAll('\\', '/')) + } + } + assert.deepEqual(references.sort(), [ + 'iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java' + ]) +}) + +test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { + const clients = new Map([ + ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', 'getUnloadPhotoFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', 'getRelabelPhotoFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', 'getWarehouseResource'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', 'getPalletPhysical'] + ]) + for (const [file, propertyAccessor] of clients) { + const source = await readFile(path.join(root, file), 'utf8') + assert.match(source, /restClientFactory\.build\(restClientBuilder, baseUrl,/) + assert.match(source, new RegExp(`restClientProperties\\.${propertyAccessor}\\(\\)`)) + assert.doesNotMatch(source, /RetryTemplate|Retryer|@Retryable|\.retry\(|fallbackFactory|fallback\s*=/i) + } + + const factory = await readFile(path.join(root, + 'lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java'), 'utf8') + assert.match(factory, /connectTimeout\(timeouts\.getConnectTimeout\(\)\)/) + assert.match(factory, /setReadTimeout\(timeouts\.getReadTimeout\(\)\)/) + assert.doesNotMatch(factory, /RetryTemplate|Retryer|@Retryable|\.retry\(|fallbackFactory|fallback\s*=/i) + + for (const configFile of [ + 'lastleg-service/src/main/resources/application.yml', + 'config/nacos/overseas/lastleg-service.yml' + ]) { + const source = await readFile(path.join(root, configFile), 'utf8') + for (const key of ['unload-photo-file', 'relabel-photo-file', 'warehouse-resource', 'pallet-physical']) { + assert.match(source, new RegExp( + `${key}:\\s*\\n\\s+connect-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}` + + `\\s*\\n\\s+read-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}`)) + } + } +}) + +test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', () => { + assert.deepEqual(graphEdges('REQUEST'), expectedOnlineEdges) + assert.deepEqual(graphEdges('BACKGROUND'), expectedBackgroundEdges) + assert.deepEqual(graphEdges('INFRASTRUCTURE'), [ + 'common-audit->audit-service', + 'gateway-overseas->auth-service', + 'gateway-overseas->lastleg-service' + ]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [ + ['auth-service', 'iam-service'], + ['firstleg-service', 'order-service'] + ]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ + 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service' + ]]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[ + 'auth-service', 'event-bridge-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('Order and Firstleg remain a bilateral REQUEST SCC until loading-complete timing is frozen', () => { + const requestClients = SYNCHRONOUS_CLIENTS.filter(client => client.contexts.includes('REQUEST')) + const orderToFirstleg = requestClients.filter(client => + client.source === 'order-service' && client.target === 'firstleg-service') + const firstlegToOrder = requestClients.filter(client => + client.source === 'firstleg-service' && client.target === 'order-service') + assert.deepEqual(orderToFirstleg.map(client => client.path).sort(), [ + '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' + ]) + assert.deepEqual(firstlegToOrder.map(client => client.path).sort(), [ + 'firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java', + 'firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java' + ]) + assert.ok(stronglyConnectedComponents(graphEdges('REQUEST')).some(component => + component.includes('firstleg-service') && component.includes('order-service'))) +}) + +test('known user entries expose every longest simple REQUEST path over the two-hop budget', () => { + const summary = Object.fromEntries(USER_ENTRIES.map(entry => { + const paths = longestSimplePaths(entry.service) + return [entry.service, { hops: paths[0].length - 1, paths: paths.map(path => path.join('->')) }] + })) + assert.deepEqual(summary, { + 'auth-service': { hops: 2, paths: [ + 'auth-service->iam-service->warehouse-service' + ] }, + 'iam-service': { hops: 1, paths: [ + 'iam-service->auth-service', + 'iam-service->warehouse-service' + ] }, + 'order-service': { hops: 4, paths: [ + 'order-service->firstleg-service->file-service->iam-service->auth-service', + 'order-service->firstleg-service->file-service->iam-service->warehouse-service' + ] }, + 'firstleg-service': { hops: 4, paths: [ + 'firstleg-service->order-service->file-service->iam-service->auth-service', + 'firstleg-service->order-service->file-service->iam-service->warehouse-service' + ] }, + 'finance-service': { hops: 5, paths: [ + 'finance-service->firstleg-service->order-service->file-service->iam-service->auth-service', + 'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service' + ] }, + 'file-service': { hops: 2, paths: [ + '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' + ] }, + 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, + 'warehouse-overseas-service': { hops: 1, paths: [ + 'warehouse-overseas-service->warehouse-service' + ] } + }) + for (const entry of USER_ENTRIES) { + const result = summary[entry.service] + if (result.hops > 2) assert.ok(result.paths.every(path => path.split('->').length > 3)) + } +}) + +test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { + for (const profile of ['local', 'cn', 'overseas']) { + 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/) + } + const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8') + assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5) +}) diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index f59c35877..70c1ea8f9 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -12,6 +12,14 @@ const ciMavenSettings = (await readFile(new URL('../deploy/maven/settings-ci.xml .replaceAll('\r\n', '\n') const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', import.meta.url), 'utf8')) .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, /params\.ENVIRONMENT == 'test' \|\| params\.SKIP_TESTS/) @@ -25,11 +33,38 @@ test('coordinator jobs present their own deployment environment as the default c assert.match(coordinatorPipeline, /\? \['development', 'test', 'staging', 'production'\]/) assert.match(coordinatorPipeline, /: \['test', 'development', 'staging', 'production'\]/) assert.match(coordinatorPipeline, /environmentChoicesForJob\(env\.JOB_BASE_NAME\)/) + assert.match(coordinatorPipeline, /jobBaseName == 'backend-java-dev'[\s\S]*?'baiyue-firstleg-dev-'/) + assert.match(coordinatorPipeline, /downstreamJobPrefixForJob\(env\.JOB_BASE_NAME\)/) +}) + +test('AUTO keeps its own baseline and retries only previously failed services', () => { + assert.match(coordinatorPipeline, /def parseAutoBuildState\(/) + assert.match(coordinatorPipeline, /def previousAutoBuildState\(/) + assert.match(coordinatorPipeline, /previousAutoBuildState\(currentBuild\.previousBuild, serviceModules\)/) + assert.match(coordinatorPipeline, /selected\.addAll\(retryServices\)/) + assert.match(coordinatorPipeline, /AUTO \$\{env\.IMAGE_TAG\} \| PENDING/) + assert.match(coordinatorPipeline, /AUTO \$\{env\.IMAGE_TAG\} \| RESULTS/) + assert.doesNotMatch(coordinatorPipeline, /commonChanged \? serviceModules/) +}) + +test('AUTO maps shared modules to their real consumers instead of rebuilding every service', () => { + const securityImpact = coordinatorPipeline.slice( + coordinatorPipeline.indexOf("'common-security/': ["), + coordinatorPipeline.indexOf("'common-openapi/': [") + ) + assert.match(coordinatorPipeline, /def fullRebuildPaths = \[/) + assert.match(coordinatorPipeline, /'common-security\/': \[/) + assert.match(coordinatorPipeline, /'common-event-contract\/': \[/) + assert.match(coordinatorPipeline, /commonPathServices\.each \{ prefix, impactedServices ->/) + assert.match(coordinatorPipeline, /selected\.addAll\(impactedServices\)/) + assert.match(securityImpact, /'auth'.*'finance'.*'file'/s) + assert.doesNotMatch(securityImpact, /gateway|print|integration-service|event-bridge-service/) }) test('production deployments cannot skip tests', () => { - assert.match(pipeline, /params\.ENVIRONMENT == 'production' && params\.SKIP_TESTS/) - assert.match(pipeline, /error\('生产环境禁止跳过测试'\)/) + assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/) + assert.match(coordinatorPipeline, /error\('生产环境禁止跳过测试'\)/) + assert.match(pipeline, /生产环境手工构建禁止跳过测试/) }) test('Java builds use the shared builder pool and serialize incremental target deployments', () => { @@ -37,16 +72,92 @@ test('Java builds use the shared builder pool and serialize incremental target d assert.match(pipeline, /\['development', 'test'\]\.contains\(params\.ENVIRONMENT\)/) assert.match(pipeline, /params\.ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) assert.match(pipeline, /'TARGET_IS_LOCAL=true'/) - assert.match(pipeline, /String deployLock = "firstleg-\$\{params\.ENVIRONMENT\}-deploy"/) - assert.match(pipeline, /lock\(resource: deployLock, inversePrecedence: true\)/) + assert.match(pipeline, /String deploySlotLabel = "firstleg-\$\{params\.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|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/) }) +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, /params\.ENVIRONMENT == 'production' && params\.SKIP_TESTS && !coordinatorPinnedCommit/) + assert.match(pipeline, /生产环境手工构建禁止跳过测试/) +}) + +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 = params\.ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) + assert.match(directDeployStage, /String serviceDeployResource = "firstleg-\$\{params\.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('development publishes every service on loopback without changing test defaults', () => { + assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PORT:-9000\}:9000/) + assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{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('parallel image publications isolate registry credentials per Jenkins job', () => { + const imageStage = pipeline.slice( + pipeline.indexOf("stage('构建并发布单服务镜像')"), + pipeline.indexOf("stage('部署开发或测试环境')") + ) + const loginIndex = imageStage.indexOf('docker login') + const buildIndex = imageStage.indexOf('docker build') + const pushIndex = imageStage.indexOf('docker push') + const logoutIndex = imageStage.indexOf('docker logout') + const cleanupIndex = imageStage.indexOf('rm -rf "$DOCKER_CONFIG"') + + assert.match(imageStage, /String dockerConfigDir = "\$\{pwd\(tmp: true\)\}\/docker-auth"/) + assert.match(imageStage, /withEnv\(\["DOCKER_CONFIG=\$\{dockerConfigDir\}"\]\)/) + assert.ok(loginIndex >= 0 && loginIndex < buildIndex, 'registry login must happen before image build') + assert.ok(buildIndex < pushIndex, 'image build must happen before image push') + assert.ok(pushIndex < logoutIndex, 'registry logout must happen after image push') + assert.ok(logoutIndex < cleanupIndex, 'temporary Docker credentials must be removed last') + 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="\$3"/) + assert.match(directDeployScript, /compose_target="\$4"/) + assert.match(directDeployScript, /mv -f "\$env_tmp" "\$env_target"/) + assert.match(directDeployScript, /mv -f "\$compose_tmp" "\$compose_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('shared builders use a reachable public Maven mirror instead of the retired private proxy', () => { assert.match(pipeline, /MAVEN_SETTINGS = 'deploy\/maven\/settings-ci\.xml'/) assert.match(ciMavenSettings, /https:\/\/maven\.aliyun\.com\/repository\/public/) @@ -80,3 +191,25 @@ test('source workspaces are preserved for incremental Git fetches', () => { assert.equal((pipeline.match(/cleanWs\(/g) ?? []).length, 2) assert.doesNotMatch(coordinatorPipeline, /cleanWs\(/) }) + +test('direct deployments wait for health and automatically restore the previous image', () => { + assert.match(directDeployScript, /HEALTH_TIMEOUT_SECONDS="\$\{DEPLOY_HEALTH_TIMEOUT_SECONDS:-240\}"/) + assert.match(directDeployScript, /previous_image="\$\(local_container_image \|\| true\)"/) + assert.match(directDeployScript, /if wait_for_local_health; then/) + assert.match(directDeployScript, /开始自动回滚到/) + assert.match(directDeployScript, /自动回滚成功/) + assert.match(directDeployScript, /自动回滚失败/) + assert.match(directDeployScript, /return 1/) +}) + +test('one event bridge build deploys both regional instances in development and test', () => { + 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'/) +}) diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md new file mode 100644 index 000000000..e0f3ff78e --- /dev/null +++ b/warehouse-overseas-service/AGENTS.md @@ -0,0 +1,47 @@ +# Overseas Warehouse Service 模块指引 + +## 职责与边界 + +- 负责海外库位、托盘及业务资源锁定/释放;只保存国内仓库 ID 引用,不保存仓库、地址或能力主档。 +- 国内 `warehouse-service` 是仓库 ID、类型、状态、地址和能力的唯一权威;本服务通过内部目录校验平台共享 `OVERSEAS + ACTIVE + RECEIVING` 仓。尾程入仓计划通过内部网关请求资源;本模块不读取尾程订单库或头程数据库。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 国内仓库只读目录 | `client/DomesticWarehouseDirectoryClient.java`、`service/DomesticWarehouseDirectory.java`;正结果缓存 30 分钟,空值和不存在不得长缓存 | +| 平台/商户库位与托盘 | `controller/PlatformWarehouseResourceController.java`、`MerchantWarehouseResourceController.java` | +| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` | +| PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` | +| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | +| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | +| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | +| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | +| 测试 | `src/test/java/com/baiyue/warehouse/` | + +## 数据与安全规则 + +- 平台和商户接口必须分别执行访问域与租户范围校验;客户端传入的 `tenantId` 不能作为唯一授权依据。 +- 平台维护的海外库位与实物托盘是公共资源,固定使用 `customer_id=0`;平台资源 API 不接收目标客户参数。资源锁记录仍使用真实业务客户 ID 隔离,但其托盘、库位查找和状态更新必须落到公共资源作用域。 +- 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。 +- 内部资源接口必须校验服务令牌;普通用户不得直接调用。 +- 本服务不得注册仓库主档创建、编辑、状态或商户仓库查询 Controller;所有资源写入先以相同 `warehouseId` 校验国内只读目录。 + +## 数据库表索引与注释约束 + +| 业务域 | 表名 | 中文含义 | +|---|---|---| +| 库位 | `wh_location` | 海外仓库位资源 | +| 托盘 | `wh_pallet` | 海外仓实物托盘资源 | +| 资源锁 | `wh_resource_lock` | 仓储资源锁定记录 | +| 托盘绑定 | `wh_pallet_business_binding` | 托盘业务绑定关系 | +| 托盘操作 | `wh_pallet_operation` | PDA 托盘物理状态流转、幂等与补偿记录 | + +- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义。 +- `SchemaChineseCommentContractTest` 自动扫描全部 `wh_` 表;新增字段必须同步更新基线 SQL、统一增量 SQL 和存量备注迁移。 + +## 修改与验证 + +- 字段、注释和索引同步维护基线 SQL 与统一增量 SQL;海外数据库禁止新增 `wh_warehouse`、`wh_warehouse_address`、`wh_service_capability` 或指向它们的外键。 +- PDA 托盘流转必须校验内部服务令牌、仓库范围、当前物理状态和幂等键;跨服务业务失败只能通过显式补偿接口恢复,不得由 Lastleg 直接修改 `wh_` 资源表。 +- 定向验证:`mvn -pl warehouse-overseas-service -am test`。 diff --git a/warehouse-overseas-service/pom.xml b/warehouse-overseas-service/pom.xml new file mode 100644 index 000000000..acce7f683 --- /dev/null +++ b/warehouse-overseas-service/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + warehouse-overseas-service + + + + com.baiyue.shared + baiyue-common-openapi + ${project.version} + + + com.baiyue.shared + baiyue-common-audit + ${project.version} + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + com.baiyue.shared + baiyue-common-web + ${project.version} + + + com.baiyue.shared + baiyue-common-security + ${project.version} + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + org.springframework.boot + spring-boot-starter-actuator + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java new file mode 100644 index 000000000..58358b7ba --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; + +/** + * 仓库微服务启动入口,启用 Spring Boot 自动配置并扫描仓库服务的 MyBatis Mapper。 + */ +@MapperScan("com.baiyue.warehouse.mapper") +@EnableFeignClients(basePackages = "com.baiyue.warehouse.client") +@SpringBootApplication(scanBasePackages = "com.baiyue") +public class BaiyueWarehouseOverseasApplication { + public static void main(String[] args) { SpringApplication.run(BaiyueWarehouseOverseasApplication.class, args); } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/DomesticWarehouseDirectoryClient.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/DomesticWarehouseDirectoryClient.java new file mode 100644 index 000000000..2e9c636e4 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/DomesticWarehouseDirectoryClient.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.client; + +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.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.util.List; + +@FeignClient(name = "warehouse-service", contextId = "sharedOverseasWarehouseDirectoryClient", + url = "${baiyue.clients.domestic-warehouse.url:}") +public interface DomesticWarehouseDirectoryClient { + @GetMapping("/internal/warehouses/shared-overseas") + ApiResponse> activeSharedWarehouses( + @RequestHeader("X-Internal-Service-Token") String internalToken); + + @GetMapping("/internal/warehouses/shared-overseas/{warehouseId}") + ApiResponse activeSharedWarehouse( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @PathVariable long warehouseId); + + record WarehouseReference(Long warehouseId, String warehouseCode, String warehouseName, + String countryCode, String status) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java new file mode 100644 index 000000000..5d115f267 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.config; + +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Prevents the warehouse schema migration from ever running against another database. */ +@Configuration(proxyBeanMethods = false) +public class WarehouseDatabaseGuardConfiguration { + private static final Pattern MYSQL_SCHEMA = Pattern.compile( + "^jdbc:mysql://[^/]+/([^?;]+)(?:[?;].*)?$", Pattern.CASE_INSENSITIVE); + + @Bean + static BeanFactoryPostProcessor warehouseDatabaseGuard(Environment environment) { + String url = environment.getProperty("spring.datasource.url", "").trim(); + Matcher matcher = MYSQL_SCHEMA.matcher(url); + if (!matcher.matches() || !"baiyue_lastlag_warehouse".equalsIgnoreCase(matcher.group(1))) { + throw new IllegalStateException( + "warehouse-overseas-service requires spring.datasource.url to target the baiyue_lastlag_warehouse database"); + } + return beanFactory -> { }; + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java new file mode 100644 index 000000000..37ffad011 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java @@ -0,0 +1,39 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.dto.PalletTransitionResponse; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehousePalletTransitionService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/v1/pda-pallet-transitions") +public class InternalPdaPalletTransitionController { + @Resource private InternalServiceTokenGuard tokenGuard; + @Resource private WarehousePalletTransitionService service; + + @PostMapping("/{palletId}") + public ApiResponse apply( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, @Valid @RequestBody PalletTransitionRequests.Apply request) { + tokenGuard.verify(token); + return ApiResponse.success(service.apply(palletId, request)); + } + + @PostMapping("/{palletId}/{operationKey}/compensate") + public ApiResponse compensate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, @PathVariable String operationKey, + @Valid @RequestBody PalletTransitionRequests.Compensate request) { + tokenGuard.verify(token); + return ApiResponse.success(service.compensate(palletId, operationKey, request)); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java new file mode 100644 index 000000000..1d651fc2d --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -0,0 +1,35 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.DropshipPalletRequests; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehouseResourceService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/internal/v1/warehouse-pallets") +public class InternalWarehousePalletController { + @Resource private WarehouseResourceService service; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping("/ensure-dropship") + public ApiResponse ensure( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody DropshipPalletRequests.Ensure request) { + tokenGuard.verify(token); + return ApiResponse.success(service.ensureDropshipPallet(request.customerId(), request.warehouseId(), request.businessId())); + } + + @GetMapping("/resolve/{palletCode}") + public ApiResponse resolve( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable String palletCode) { + tokenGuard.verify(token); + return ApiResponse.success(service.resolvePublicPallet(palletCode)); + } + +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java new file mode 100644 index 000000000..5dff5af01 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java @@ -0,0 +1,100 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; +import com.baiyue.warehouse.dto.SharedWarehouseReference; +import com.baiyue.warehouse.dto.WarehouseLocationResolveResponse; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehouseResourceLockService; +import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.service.DomesticWarehouseDirectory; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/v1/warehouse-resource-locks") +public class InternalWarehouseResourceLockController { + @Resource private WarehouseResourceLockService service; + @Resource private WarehouseResourceService resourceService; + @Resource private DomesticWarehouseDirectory warehouses; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping + public ApiResponse reserve( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WarehouseResourceLockRequests.ReserveRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.reserve(request)); + } + + @PostMapping("/{lockId}/release") + public ApiResponse release( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long lockId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.release(request.customerId(), lockId)); + } + + @PostMapping("/{lockId}/scrap") + public ApiResponse scrap( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long lockId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.scrap(request.customerId(), lockId)); + } + + @PostMapping("/pallets/{palletId}/scrap") + public ApiResponse scrapPallet( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.scrapByPallet(request.customerId(), palletId)); + } + + @PostMapping("/default-locations/ensure") + public ApiResponse ensureDefaultLocation( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WarehouseResourceLockRequests.DefaultLocationRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(resourceService.ensureDefaultVirtualLocation( + request.customerId(), request.warehouseId())); + } + + @GetMapping("/shared-warehouses/{warehouseId}") + public ApiResponse sharedWarehouse( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long warehouseId) { + tokenGuard.verify(token); + return ApiResponse.success(warehouses.required(warehouseId)); + } + + @GetMapping("/locations/resolve/{locationCode}") + public ApiResponse resolveLocation( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam long warehouseId, @PathVariable String locationCode) { + tokenGuard.verify(token); + return ApiResponse.success(resourceService.resolvePublicLocation(warehouseId, locationCode)); + } + + @GetMapping("/locations/{locationId}") + public ApiResponse location( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam long warehouseId, @PathVariable long locationId) { + tokenGuard.verify(token); + return ApiResponse.success(resourceService.resolvePublicLocation(warehouseId, locationId)); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java new file mode 100644 index 000000000..0ba243967 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java @@ -0,0 +1,109 @@ +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.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.service.WarehouseResourceService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/merchant/v1/warehouses/{warehouseId}") +public class MerchantWarehouseResourceController { + @Resource private WarehouseResourceService service; + + @PostMapping("/locations") + public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @Valid @RequestBody LocationRequests.SaveRequest request) { + return ApiResponse.success(service.createLocation(tenant(user), warehouseId, request)); + } + + @PutMapping("/locations/{id}") + public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody LocationRequests.SaveRequest request) { + service.updateLocation(tenant(user), warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/locations") + public ApiResponse> locations( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, + @Valid LocationRequests.QueryRequest query) { + return ApiResponse.success(service.locations(tenant(user), warehouseId, query)); + } + + @GetMapping("/locations/{id}") + public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @PathVariable long id) { + return ApiResponse.success(service.location(tenant(user), warehouseId, id)); + } + + @DeleteMapping("/locations/{id}") + public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + service.deleteLocation(tenant(user), warehouseId, id); + return ApiResponse.success(null); + } + + @PostMapping("/pallets") + public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @Valid @RequestBody PalletRequests.SaveRequest request) { + return ApiResponse.success(service.createPallet(tenant(user), warehouseId, request)); + } + + @PutMapping("/pallets/{id}") + public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody PalletRequests.SaveRequest request) { + service.updatePallet(tenant(user), warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/pallets") + public ApiResponse> pallets( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, + @Valid PalletRequests.QueryRequest query) { + return ApiResponse.success(service.pallets(tenant(user), warehouseId, query)); + } + + @GetMapping("/pallets/{id}") + public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + return ApiResponse.success(service.pallet(tenant(user), warehouseId, id)); + } + + @DeleteMapping("/pallets/{id}") + public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + service.deletePallet(tenant(user), warehouseId, id); + return ApiResponse.success(null); + } + + private static long tenant(AuthenticatedUser user) { + if (user == null || user.tenantId() == null || user.tenantId() <= 0) { + throw new BizException(ResultCode.UNAUTHORIZED, "认证租户缺失"); + } + return user.tenantId(); + } +} 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 new file mode 100644 index 000000000..be951ca52 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -0,0 +1,144 @@ +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.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.security.OverseasWarehousePermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/platform/v1/warehouse-admin/{warehouseId}") +@Tag(name = "Platform overseas warehouse resources") +public class PlatformWarehouseResourceController { + @Resource private WarehouseResourceService service; + + @PostMapping("/locations") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_CREATE + "')") + @Operation(summary = "Create a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_CREATE) + public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @Valid @RequestBody LocationRequests.SaveRequest request) { + requirePlatform(user); + return ApiResponse.success(service.createLocation(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, request)); + } + + @PutMapping("/locations/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_EDIT + "')") + @Operation(summary = "Update a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_EDIT) + public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody LocationRequests.SaveRequest request) { + requirePlatform(user); + service.updateLocation(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/locations") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_LIST + "')") + @Operation(summary = "Page warehouse locations", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_LIST) + public ApiResponse> locations( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) { + requirePlatform(user); + return ApiResponse.success(service.locations(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, query)); + } + + @GetMapping("/locations/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_LIST + "')") + @Operation(summary = "Get a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_LIST) + public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @PathVariable long id) { + requirePlatform(user); + return ApiResponse.success(service.location(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id)); + } + + @DeleteMapping("/locations/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_DELETE + "')") + @Operation(summary = "Delete a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_DELETE) + public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + requirePlatform(user); + service.deleteLocation(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id); + return ApiResponse.success(null); + } + + @PostMapping("/pallets") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_CREATE + "')") + @Operation(summary = "Create a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_CREATE) + public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @Valid @RequestBody PalletRequests.SaveRequest request) { + requirePlatform(user); + return ApiResponse.success(service.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, request)); + } + + @PutMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_EDIT + "')") + @Operation(summary = "Update a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_EDIT) + public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody PalletRequests.SaveRequest request) { + requirePlatform(user); + service.updatePallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/pallets") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')") + @Operation(summary = "Page warehouse pallets", description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST) + public ApiResponse> pallets( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) { + requirePlatform(user); + return ApiResponse.success(service.pallets(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, query)); + } + + @GetMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')") + @Operation(summary = "Get a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST) + public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + requirePlatform(user); + return ApiResponse.success(service.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id)); + } + + @DeleteMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_DELETE + "')") + @Operation(summary = "Delete a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_DELETE) + public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + requirePlatform(user); + service.deletePallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + } +} 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 new file mode 100644 index 000000000..795623301 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java @@ -0,0 +1,26 @@ +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/dto/DropshipPalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/DropshipPalletRequests.java new file mode 100644 index 000000000..b5b99da39 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/DropshipPalletRequests.java @@ -0,0 +1,9 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public final class DropshipPalletRequests { + private DropshipPalletRequests() {} + public record Ensure(@Positive long customerId, @Positive long warehouseId, @NotBlank String businessId) {} +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java new file mode 100644 index 000000000..2013ef02a --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java @@ -0,0 +1,39 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public final class LocationRequests { + private LocationRequests() { + } + + public record SaveRequest( + @NotBlank @Size(max = 50) String zoneCode, + @NotBlank @Size(max = 64) String locationCode, + @NotBlank @Size(max = 32) String locationType, + @NotNull @DecimalMin(value = "0.000", inclusive = false) BigDecimal maxLoadKg, + @NotBlank String status, + @Size(max = 500) String remark + ) { + } + + public record QueryRequest( + @Min(1) long page, + @Min(1) @Max(200) long size, + String zoneCode, + String locationType, + String status, + String keyword + ) { + public QueryRequest { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java new file mode 100644 index 000000000..d209bfb06 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -0,0 +1,59 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public final class PalletRequests { + private static final BigDecimal DEFAULT_CAPACITY = new BigDecimal("2.0000"); + + private PalletRequests() { + } + + public record SaveRequest( + @Size(max = 128) String palletCode, + @NotBlank @Size(max = 32) String palletType, + @DecimalMin(value = "0.0000", inclusive = false) BigDecimal capacityCbm, + @NotNull @DecimalMin("0.00") BigDecimal unitPrice, + @NotBlank String status, + Long locationId, + @Size(max = 500) String remark, + @Size(max = 128) String fbaWarehouseCode, + @Min(0) Integer version + ) { + public SaveRequest(String palletCode, String palletType, BigDecimal capacityCbm, + BigDecimal unitPrice, String status, Long locationId, String remark) { + this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, null, null); + } + + public SaveRequest(String palletCode, String palletType, BigDecimal capacityCbm, + BigDecimal unitPrice, String status, Long locationId, String remark, + String fbaWarehouseCode) { + this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, + fbaWarehouseCode, null); + } + + public SaveRequest { + if (capacityCbm == null) capacityCbm = DEFAULT_CAPACITY; + } + } + + public record QueryRequest( + @Min(1) long page, + @Min(1) @Max(200) long size, + String palletType, + String status, + Long locationId, + String keyword + ) { + public QueryRequest { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java new file mode 100644 index 000000000..9d8bdc683 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java @@ -0,0 +1,20 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class PalletTransitionRequests { + private PalletTransitionRequests() { } + + public record Apply(@Positive long warehouseId, + @PositiveOrZero int expectedVersion, + @NotBlank @Size(max = 20) String targetStatus, + @NotNull @Positive Long targetLocationId, + @NotBlank @Size(max = 128) String operationKey, + @Size(max = 500) String reason) { } + + public record Compensate(@Positive long warehouseId) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java new file mode 100644 index 000000000..c3e159661 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.dto; + +public record PalletTransitionResponse( + String operationKey, + long palletId, + String palletCode, + long warehouseId, + String beforeStatus, + String afterStatus, + long beforeLocationId, + long afterLocationId, + int beforeVersion, + int afterVersion, + String operationStatus, + boolean replayed +) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java new file mode 100644 index 000000000..aa83148fd --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java @@ -0,0 +1,4 @@ +package com.baiyue.warehouse.dto; + +public record SharedWarehouseReference(long warehouseId, String warehouseCode, + String warehouseName, String status) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java new file mode 100644 index 000000000..571c15925 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java @@ -0,0 +1,6 @@ +package com.baiyue.warehouse.dto; + +import java.math.BigDecimal; + +public record WarehouseLocationResolveResponse(long locationId, String locationCode, String locationType, + BigDecimal maxLoadKg, String status) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java new file mode 100644 index 000000000..be3e2610c --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java @@ -0,0 +1,12 @@ +package com.baiyue.warehouse.dto; + +public record WarehousePalletResolveResponse( + long palletId, + String palletCode, + long warehouseId, + long locationId, + String status, + String fbaWarehouseCode, + int version +) { +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java new file mode 100644 index 000000000..386fb007c --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java @@ -0,0 +1,4 @@ +package com.baiyue.warehouse.dto; + +public record WarehousePalletScrapResponse(long palletId, long warehouseId, Long locationId, String status) { +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java new file mode 100644 index 000000000..042ec6dea --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public final class WarehouseResourceLockRequests { + private WarehouseResourceLockRequests() {} + + public record ReserveRequest( + @Positive long customerId, + @Positive long warehouseId, + @Positive long palletId, + @Positive long locationId, + @NotBlank String businessType, + @NotBlank String businessId, + @NotBlank String reservationKey + ) {} + + public record CloseRequest(@Positive long customerId) {} + + public record DefaultLocationRequest( + @Positive long customerId, + @Positive long warehouseId + ) {} +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java new file mode 100644 index 000000000..332b92ba5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; + +import java.time.LocalDateTime; + +public record WarehouseResourceLockResponse( + long lockId, + long customerId, + long warehouseId, + long palletId, + long locationId, + String businessType, + String businessId, + String reservationKey, + String status, + LocalDateTime lockedAt, + LocalDateTime releasedAt +) { + public static WarehouseResourceLockResponse from(WarehouseResourceLockEntity entity) { + return new WarehouseResourceLockResponse(entity.getId(), entity.getCustomerId(), entity.getWarehouseId(), + entity.getPalletId(), entity.getLocationId(), entity.getBusinessType(), entity.getBusinessId(), + entity.getReservationKey(), entity.getStatus(), entity.getLockedAt(), entity.getReleasedAt()); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java new file mode 100644 index 000000000..9027321f5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@TableName("wh_location") +public class WarehouseLocationEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long customerId; + private Long warehouseId; + private String zoneCode; + private String locationCode; + private String locationType; + private BigDecimal maxLoadKg; + private String status; + private String remark; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBindingEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBindingEntity.java new file mode 100644 index 000000000..b456960b9 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBindingEntity.java @@ -0,0 +1,19 @@ +package com.baiyue.warehouse.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; + +@Data +@TableName("wh_pallet_business_binding") +public class WarehousePalletBindingEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long customerId; + private Long warehouseId; + private String businessType; + private String businessId; + private Long palletId; + private LocalDateTime createdAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java new file mode 100644 index 000000000..91872198c --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java @@ -0,0 +1,28 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@TableName("wh_pallet") +public class WarehousePalletEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long customerId; + private Long warehouseId; + private Long locationId; + private String palletCode; + private String palletType; + private BigDecimal capacityCbm; + private BigDecimal unitPrice; + private String status; + private String remark; + private String fbaWarehouseCode; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java new file mode 100644 index 000000000..f9bfbc36e --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.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; + +@Data +@TableName("wh_pallet_operation") +public class WarehousePalletOperationEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String operationKey; + private String requestHash; + private Long warehouseId; + private Long palletId; + private String palletCode; + private String beforeStatus; + private String afterStatus; + private Long beforeLocationId; + private Long afterLocationId; + private Integer beforeVersion; + private Integer afterVersion; + private String operationStatus; + private String reason; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java new file mode 100644 index 000000000..e401318e9 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("wh_resource_lock") +public class WarehouseResourceLockEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long customerId; + private Long warehouseId; + private Long palletId; + private Long locationId; + private String businessType; + private String businessId; + private String reservationKey; + private String status; + private LocalDateTime lockedAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java new file mode 100644 index 000000000..813b2a69d --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; + +public interface WarehouseLocationMapper extends BaseMapper { + /** + * The unique location-code index makes this safe for concurrent first use of an older warehouse. + */ + @Insert(""" + INSERT IGNORE INTO wh_location + (id, customer_id, warehouse_id, zone_code, location_code, location_type, + max_load_kg, status, remark, deleted, version) + VALUES + (#{location.id}, #{location.customerId}, #{location.warehouseId}, #{location.zoneCode}, + #{location.locationCode}, #{location.locationType}, #{location.maxLoadKg}, + #{location.status}, #{location.remark}, 0, 0) + """) + int insertDefaultVirtualLocationIgnore(@Param("location") WarehouseLocationEntity location); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java new file mode 100644 index 000000000..d0e7b44c5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.warehouse.mapper; + +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +public interface WarehousePalletBindingMapper extends BaseMapper { + @Select("SELECT * FROM wh_pallet_business_binding WHERE customer_id=#{customerId} AND warehouse_id=#{warehouseId} " + + "AND business_type=#{businessType} AND business_id=#{businessId} LIMIT 1") + WarehousePalletBindingEntity find(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("businessType") String businessType, @Param("businessId") String businessId); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java new file mode 100644 index 000000000..fb225f1f0 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java @@ -0,0 +1,6 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehousePalletEntity; + +public interface WarehousePalletMapper extends BaseMapper { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java new file mode 100644 index 000000000..9f4b423ba --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +public interface WarehousePalletTransitionMapper extends BaseMapper { + @Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1") + WarehousePalletOperationEntity findOperation(@Param("operationKey") String operationKey); + + @Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1 FOR UPDATE") + WarehousePalletOperationEntity lockOperation(@Param("operationKey") String operationKey); + + @Select("SELECT * FROM wh_pallet WHERE id=#{palletId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND deleted=0 LIMIT 1 FOR UPDATE") + WarehousePalletEntity lockPublicPallet(@Param("warehouseId") long warehouseId, + @Param("palletId") long palletId); + + @Select("SELECT COUNT(*) FROM wh_location WHERE id=#{locationId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND status<>'INACTIVE' AND deleted=0") + long countActiveLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId); + + @Update("UPDATE wh_pallet SET status=#{targetStatus},location_id=#{targetLocationId},version=version+1," + + "updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status<>'SCRAPPED' AND deleted=0") + int transition(@Param("warehouseId") long warehouseId, @Param("palletId") long palletId, + @Param("targetStatus") String targetStatus, @Param("targetLocationId") long targetLocationId, + @Param("expectedVersion") int expectedVersion); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java new file mode 100644 index 000000000..962a33999 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java @@ -0,0 +1,100 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +public interface WarehouseResourceLockMapper extends BaseMapper { + @Select(""" + SELECT * FROM wh_resource_lock + WHERE customer_id = #{customerId} AND reservation_key = #{reservationKey} + LIMIT 1 + """) + WarehouseResourceLockEntity findByReservationKey(@Param("customerId") long customerId, + @Param("reservationKey") String reservationKey); + + @Select("SELECT * FROM wh_resource_lock WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " + + "AND status='ACTIVE' ORDER BY locked_at DESC LIMIT 1") + WarehouseResourceLockEntity findActiveByPallet(@Param("customerId") long customerId, + @Param("palletId") long palletId); + + @Update(""" + UPDATE wh_pallet + SET status = 'OCCUPIED', location_id = #{locationId}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND status = 'IDLE' AND deleted = 0 + """) + int occupyPallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_location + SET status = 'OCCUPIED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{locationId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND status = 'IDLE' AND deleted = 0 + """) + int occupyLocation(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'IDLE', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int releasePallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int releasePalletAtVirtualLocation(@Param("customerId") long customerId, + @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, + @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_location + SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{locationId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND status = 'OCCUPIED' AND deleted = 0 + """) + int releaseLocation(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'SCRAPPED', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int scrapPallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'SCRAPPED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int scrapPalletAtVirtualLocation(@Param("customerId") long customerId, + @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, + @Param("locationId") long locationId); + + @Update("UPDATE wh_pallet SET status='SCRAPPED',updated_at=CURRENT_TIMESTAMP(3),version=version+1 " + + "WHERE customer_id=#{customerId} AND id=#{palletId} AND status='OCCUPIED' AND deleted=0") + int scrapPalletWithoutLock(@Param("customerId") long customerId, @Param("palletId") long palletId); + + @Update(""" + UPDATE wh_resource_lock + SET status = #{status}, released_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3) + WHERE id = #{id} AND status = 'ACTIVE' + """) + int closeLock(@Param("id") long id, @Param("status") String status); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java new file mode 100644 index 000000000..a2fb28afa --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java @@ -0,0 +1,92 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.mapper.WarehouseLocationMapper; +import com.baiyue.warehouse.mapper.WarehousePalletMapper; +import com.baiyue.warehouse.mapper.WarehouseResourceLockMapper; +import com.baiyue.warehouse.service.WarehouseResourceService; +import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; + +@Repository +public class WarehouseResourceLockRepository { + private static final long PUBLIC_RESOURCE_SCOPE = WarehouseResourceService.PUBLIC_RESOURCE_SCOPE; + @Resource private WarehouseResourceLockMapper locks; + @Resource private WarehousePalletMapper pallets; + @Resource private WarehouseLocationMapper locations; + + public WarehouseResourceLockEntity findByReservationKey(long customerId, String reservationKey) { + return locks.findByReservationKey(customerId, reservationKey); + } + + public WarehouseResourceLockEntity find(long customerId, long lockId) { + return locks.selectOne(new LambdaQueryWrapper() + .eq(WarehouseResourceLockEntity::getCustomerId, customerId) + .eq(WarehouseResourceLockEntity::getId, lockId)); + } + + public WarehouseResourceLockEntity findActiveByPallet(long customerId, long palletId) { + return locks.findActiveByPallet(customerId, palletId); + } + + public WarehousePalletEntity findPallet(long customerId, long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, PUBLIC_RESOURCE_SCOPE) + .eq(WarehousePalletEntity::getId, palletId)); + } + + public WarehousePalletEntity findPallet(long customerId, long warehouseId, long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, PUBLIC_RESOURCE_SCOPE) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getId, palletId)); + } + + public WarehouseLocationEntity findLocation(long customerId, long warehouseId, long locationId) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, PUBLIC_RESOURCE_SCOPE) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getId, locationId)); + } + + public int occupyPallet(long customerId, long warehouseId, long palletId, long locationId) { + return locks.occupyPallet(PUBLIC_RESOURCE_SCOPE, warehouseId, palletId, locationId); + } + + public int occupyLocation(long customerId, long warehouseId, long locationId) { + return locks.occupyLocation(PUBLIC_RESOURCE_SCOPE, warehouseId, locationId); + } + + public void insert(WarehouseResourceLockEntity lock) { locks.insert(lock); } + + public int releasePallet(WarehouseResourceLockEntity lock) { + return locks.releasePallet(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); + } + + public int releasePalletAtVirtualLocation(WarehouseResourceLockEntity lock) { + return locks.releasePalletAtVirtualLocation(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), + lock.getPalletId(), lock.getLocationId()); + } + + public int scrapPallet(WarehouseResourceLockEntity lock) { + return locks.scrapPallet(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); + } + + public int scrapPalletWithoutLock(long customerId, long palletId) { + return locks.scrapPalletWithoutLock(PUBLIC_RESOURCE_SCOPE, palletId); + } + + public int scrapPalletAtVirtualLocation(WarehouseResourceLockEntity lock) { + return locks.scrapPalletAtVirtualLocation(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), + lock.getPalletId(), lock.getLocationId()); + } + + public int releaseLocation(WarehouseResourceLockEntity lock) { + return locks.releaseLocation(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getLocationId()); + } + + public int close(WarehouseResourceLockEntity lock, String status) { return locks.closeLock(lock.getId(), status); } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java new file mode 100644 index 000000000..32b854ea8 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -0,0 +1,152 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.mapper.WarehouseLocationMapper; +import com.baiyue.warehouse.mapper.WarehousePalletMapper; +import com.baiyue.warehouse.mapper.WarehousePalletBindingMapper; +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import jakarta.annotation.Resource; + +import java.util.List; + +@Repository +public class WarehouseResourceRepository { + @Resource private WarehouseLocationMapper locations; + @Resource private WarehousePalletMapper pallets; + @Resource private WarehousePalletBindingMapper bindings; + + public WarehousePalletBindingEntity findPalletBinding(long customerId, long warehouseId, + String businessType, String businessId) { + return bindings.find(customerId, warehouseId, businessType, businessId); + } + + public void insertPalletBinding(WarehousePalletBindingEntity entity) { + bindings.insert(entity); + } + + public WarehouseLocationEntity findDefaultVirtualLocation(long customerId, long warehouseId) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationType, "VIRTUAL") + .eq(WarehouseLocationEntity::getStatus, "ACTIVE") + .orderByAsc(WarehouseLocationEntity::getCreatedAt) + .last("LIMIT 1")); + } + + public WarehouseLocationEntity findLocationByCode(long customerId, long warehouseId, String locationCode) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, locationCode)); + } + + public void insertLocation(WarehouseLocationEntity entity) { + locations.insert(entity); + } + + public int insertDefaultVirtualLocationIgnore(WarehouseLocationEntity entity) { + return locations.insertDefaultVirtualLocationIgnore(entity); + } + + public void updateLocation(WarehouseLocationEntity entity) { + locations.updateById(entity); + } + + public WarehouseLocationEntity findLocation(long customerId, long warehouseId, long id) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getId, id)); + } + + public boolean locationCodeExists(long customerId, long warehouseId, String code, Long excludedId) { + return locations.selectCount(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, code) + .ne(excludedId != null, WarehouseLocationEntity::getId, excludedId)) > 0; + } + + public PageResult pageLocations(long customerId, long warehouseId, + LocationRequests.QueryRequest query) { + var wrapper = new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(StringUtils.hasText(query.zoneCode()), WarehouseLocationEntity::getZoneCode, query.zoneCode()) + .eq(StringUtils.hasText(query.locationType()), WarehouseLocationEntity::getLocationType, query.locationType()) + .eq(StringUtils.hasText(query.status()), WarehouseLocationEntity::getStatus, query.status()) + .and(StringUtils.hasText(query.keyword()), q -> q + .like(WarehouseLocationEntity::getLocationCode, query.keyword()) + .or().like(WarehouseLocationEntity::getZoneCode, query.keyword())) + .orderByAsc(WarehouseLocationEntity::getZoneCode) + .orderByAsc(WarehouseLocationEntity::getLocationCode); + return PageResult.from(locations.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public void deleteLocation(long id) { + locations.deleteById(id); + } + + public boolean locationHasPallets(long customerId, long warehouseId, long locationId) { + return pallets.selectCount(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getLocationId, locationId)) > 0; + } + + public void insertPallet(WarehousePalletEntity entity) { + pallets.insert(entity); + } + + public int updatePallet(WarehousePalletEntity entity) { + return pallets.updateById(entity); + } + + public WarehousePalletEntity findPallet(long customerId, long warehouseId, long id) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getId, id)); + } + + public List findPublicPalletsByCode(String palletCode) { + return pallets.selectList(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, 0L) + .eq(WarehousePalletEntity::getPalletCode, palletCode) + .orderByAsc(WarehousePalletEntity::getId)); + } + + public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) { + return pallets.selectCount(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getPalletCode, code) + .ne(excludedId != null, WarehousePalletEntity::getId, excludedId)) > 0; + } + + public PageResult pagePallets(long customerId, long warehouseId, + PalletRequests.QueryRequest query) { + var wrapper = new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(StringUtils.hasText(query.palletType()), WarehousePalletEntity::getPalletType, query.palletType()) + .eq(StringUtils.hasText(query.status()), WarehousePalletEntity::getStatus, query.status()) + .eq(query.locationId() != null, WarehousePalletEntity::getLocationId, query.locationId()) + .like(StringUtils.hasText(query.keyword()), WarehousePalletEntity::getPalletCode, query.keyword()) + .orderByDesc(WarehousePalletEntity::getCreatedAt); + return PageResult.from(pallets.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public void deletePallet(long id) { + pallets.deleteById(id); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java new file mode 100644 index 000000000..34ec759d3 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.security; + +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.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + private final byte[] expected; + + public InternalServiceTokenGuard( + @Value("${baiyue.internal-service.token:phase1-local-internal-token-at-least-32-bytes}") String token) { + this.expected = token.getBytes(StandardCharsets.UTF_8); + } + + public void verify(String provided) { + byte[] actual = provided == null ? new byte[0] : provided.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expected, actual)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid internal service token"); + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java new file mode 100644 index 000000000..acac5005f --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java @@ -0,0 +1,19 @@ +package com.baiyue.warehouse.security; + +/** Canonical Admin authorities for overseas warehouse resources. */ +public final class OverseasWarehousePermissions { + public static final String WAREHOUSE_LIST = "platform:overseas-warehouse:warehouse:list"; + public static final String WAREHOUSE_CREATE = "platform:overseas-warehouse:warehouse:create"; + public static final String WAREHOUSE_EDIT = "platform:overseas-warehouse:warehouse:edit"; + public static final String WAREHOUSE_STATUS = "platform:overseas-warehouse:warehouse:status"; + public static final String LOCATION_LIST = "platform:overseas-warehouse:location:list"; + public static final String LOCATION_CREATE = "platform:overseas-warehouse:location:create"; + public static final String LOCATION_EDIT = "platform:overseas-warehouse:location:edit"; + public static final String LOCATION_DELETE = "platform:overseas-warehouse:location:delete"; + public static final String PALLET_LIST = "platform:overseas-warehouse:pallet:list"; + public static final String PALLET_CREATE = "platform:overseas-warehouse:pallet:create"; + public static final String PALLET_EDIT = "platform:overseas-warehouse:pallet:edit"; + public static final String PALLET_DELETE = "platform:overseas-warehouse:pallet:delete"; + + private OverseasWarehousePermissions() { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/DomesticWarehouseDirectory.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/DomesticWarehouseDirectory.java new file mode 100644 index 000000000..8e4d6e408 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/DomesticWarehouseDirectory.java @@ -0,0 +1,69 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.client.DomesticWarehouseDirectoryClient; +import com.baiyue.warehouse.dto.SharedWarehouseReference; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class DomesticWarehouseDirectory { + @Resource private DomesticWarehouseDirectoryClient client; + @Value("${baiyue.security.internal-service-token:${baiyue.internal-service.token}}") + private String internalToken; + @Value("${baiyue.clients.domestic-warehouse.cache-ttl:30m}") private Duration ttl; + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private Clock clock = Clock.systemUTC(); + + public SharedWarehouseReference required(long warehouseId) { + CachedWarehouse current = cache.get(warehouseId); + Instant now = clock.instant(); + if (current != null && now.isBefore(current.expiresAt())) return current.warehouse(); + var response = client.activeSharedWarehouse(internalToken, warehouseId); + if (response == null || !response.successful() || response.data() == null + || !valid(response.data())) { + cache.remove(warehouseId); + throw new BizException(ResultCode.NOT_FOUND, + "启用的公共海外收货仓不存在"); + } + SharedWarehouseReference warehouse = toReference(response.data()); + cache.put(warehouseId, new CachedWarehouse(warehouse, now.plus(ttl))); + return warehouse; + } + + public List options() { + var response = client.activeSharedWarehouses(internalToken); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内仓库目录暂不可用"); + } + List warehouses = response.data().stream() + .filter(this::valid).map(this::toReference).toList(); + if (!warehouses.isEmpty()) { + Instant expiresAt = clock.instant().plus(ttl); + warehouses.forEach(value -> cache.put(value.warehouseId(), new CachedWarehouse(value, expiresAt))); + } + return warehouses; + } + + private boolean valid(DomesticWarehouseDirectoryClient.WarehouseReference value) { + return value.warehouseId() != null && value.warehouseId() > 0 + && StringUtils.hasText(value.warehouseCode()) + && StringUtils.hasText(value.warehouseName()) && "ACTIVE".equals(value.status()); + } + + private SharedWarehouseReference toReference(DomesticWarehouseDirectoryClient.WarehouseReference value) { + return new SharedWarehouseReference(value.warehouseId(), value.warehouseCode().trim(), + value.warehouseName().trim(), value.status()); + } + + private record CachedWarehouse(SharedWarehouseReference warehouse, Instant expiresAt) { } +} 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 new file mode 100644 index 000000000..bf2782ec6 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java @@ -0,0 +1,106 @@ +package com.baiyue.warehouse.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.dto.PalletTransitionResponse; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Set; + +@Service +public class WarehousePalletTransitionService { + private static final Set MUTABLE_STATUSES = Set.of("IDLE", "OCCUPIED"); + @Resource private WarehousePalletTransitionMapper mapper; + + @Transactional + public PalletTransitionResponse apply(long palletId, PalletTransitionRequests.Apply request) { + String key = request.operationKey().trim(); + String status = request.targetStatus().trim().toUpperCase(); + if (!MUTABLE_STATUSES.contains(status)) { + throw new BizException(ResultCode.BAD_REQUEST, "托盘作业状态只允许 IDLE 或 OCCUPIED"); + } + String requestHash = hash(request.warehouseId() + "|" + palletId + "|" + request.expectedVersion() + + "|" + status + "|" + request.targetLocationId()); + WarehousePalletOperationEntity existing = mapper.findOperation(key); + if (existing != null) return replay(existing, requestHash); + + WarehousePalletEntity pallet = mapper.lockPublicPallet(request.warehouseId(), palletId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "当前冻结仓内托盘不存在"); + int version = pallet.getVersion() == null ? 0 : pallet.getVersion(); + if (version != request.expectedVersion()) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码"); + } + if (mapper.countActiveLocation(request.warehouseId(), request.targetLocationId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "目标库位无效或已停用"); + } + if (pallet.getLocationId() == null) { + throw new BizException(ResultCode.CONFLICT, "托盘没有有效库位,不能执行库内作业"); + } + if (mapper.transition(request.warehouseId(), palletId, status, request.targetLocationId(), version) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码"); + } + WarehousePalletOperationEntity operation = new WarehousePalletOperationEntity(); + operation.setId(IdWorker.getId()); operation.setOperationKey(key); operation.setRequestHash(requestHash); + operation.setWarehouseId(request.warehouseId()); operation.setPalletId(palletId); + operation.setPalletCode(pallet.getPalletCode()); operation.setBeforeStatus(pallet.getStatus()); + operation.setAfterStatus(status); operation.setBeforeLocationId(pallet.getLocationId()); + operation.setAfterLocationId(request.targetLocationId()); operation.setBeforeVersion(version); + operation.setAfterVersion(version + 1); operation.setOperationStatus("APPLIED"); + operation.setReason(trim(request.reason())); operation.setCreatedAt(LocalDateTime.now()); + operation.setUpdatedAt(operation.getCreatedAt()); mapper.insert(operation); + return response(operation, false); + } + + @Transactional + public PalletTransitionResponse compensate(long palletId, String operationKey, + PalletTransitionRequests.Compensate request) { + WarehousePalletOperationEntity operation = mapper.lockOperation(operationKey); + if (operation == null || !Long.valueOf(palletId).equals(operation.getPalletId()) + || !Long.valueOf(request.warehouseId()).equals(operation.getWarehouseId())) { + throw new BizException(ResultCode.NOT_FOUND, "托盘状态变更记录不存在"); + } + if ("COMPENSATED".equals(operation.getOperationStatus())) return response(operation, true); + if (!"APPLIED".equals(operation.getOperationStatus())) { + throw new BizException(ResultCode.CONFLICT, "托盘状态变更不可补偿"); + } + if (mapper.transition(request.warehouseId(), palletId, operation.getBeforeStatus(), + operation.getBeforeLocationId(), operation.getAfterVersion()) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘已被后续作业修改,自动补偿失败"); + } + operation.setOperationStatus("COMPENSATED"); operation.setUpdatedAt(LocalDateTime.now()); + mapper.updateById(operation); + return response(operation, false); + } + + private PalletTransitionResponse replay(WarehousePalletOperationEntity operation, String hash) { + if (!hash.equals(operation.getRequestHash())) { + throw new BizException(ResultCode.CONFLICT, "操作幂等键已被不同托盘请求使用"); + } + return response(operation, true); + } + private static PalletTransitionResponse response(WarehousePalletOperationEntity value, boolean replayed) { + return new PalletTransitionResponse(value.getOperationKey(), value.getPalletId(), value.getPalletCode(), + value.getWarehouseId(), value.getBeforeStatus(), value.getAfterStatus(), value.getBeforeLocationId(), + value.getAfterLocationId(), value.getBeforeVersion(), value.getAfterVersion(), + value.getOperationStatus(), replayed); + } + private static String hash(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java new file mode 100644 index 000000000..b3ef444ff --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java @@ -0,0 +1,158 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.repository.WarehouseResourceLockRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Service +public class WarehouseResourceLockService { + @Resource private WarehouseResourceLockRepository repository; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public WarehouseResourceLockResponse reserve(WarehouseResourceLockRequests.ReserveRequest request) { + WarehouseResourceLockEntity existing = repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (existing != null) return idempotentResult(existing, request); + + WarehousePalletEntity pallet = repository.findPallet(request.customerId(), request.warehouseId(), request.palletId()); + WarehouseLocationEntity location = repository.findLocation(request.customerId(), request.warehouseId(), request.locationId()); + if (pallet == null || location == null) { + WarehouseResourceLockEntity concurrent = + repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (concurrent != null) return idempotentResult(concurrent, request); + throw new BizException(ResultCode.NOT_FOUND, "Pallet or location does not belong to the tenant warehouse"); + } + boolean virtualLocation = isVirtual(location); + boolean locationAvailable = virtualLocation ? "ACTIVE".equals(location.getStatus()) : "IDLE".equals(location.getStatus()); + if (!"IDLE".equals(pallet.getStatus()) || !locationAvailable) { + WarehouseResourceLockEntity concurrent = + repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (concurrent != null) return idempotentResult(concurrent, request); + throw new BizException(ResultCode.CONFLICT, "Only IDLE pallet and location can be reserved"); + } + if (repository.occupyPallet(request.customerId(), request.warehouseId(), request.palletId(), request.locationId()) != 1) { + return concurrentIdempotentOrConflict(request); + } + if (!virtualLocation && repository.occupyLocation(request.customerId(), request.warehouseId(), request.locationId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "Location is no longer IDLE"); + } + + WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity(); + lock.setCustomerId(request.customerId()); + lock.setWarehouseId(request.warehouseId()); + lock.setPalletId(request.palletId()); + lock.setLocationId(request.locationId()); + lock.setBusinessType(request.businessType()); + lock.setBusinessId(request.businessId()); + lock.setReservationKey(request.reservationKey()); + lock.setStatus("ACTIVE"); + lock.setLockedAt(LocalDateTime.now()); + repository.insert(lock); + return WarehouseResourceLockResponse.from(lock); + } + + @Transactional + public WarehouseResourceLockResponse release(long customerId, long lockId) { + WarehouseResourceLockEntity lock = requireLock(customerId, lockId); + if ("RELEASED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); + requireActive(lock); + boolean virtualLocation = isVirtualLockLocation(lock); + int palletResult = virtualLocation ? repository.releasePalletAtVirtualLocation(lock) : repository.releasePallet(lock); + if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1) + || repository.close(lock, "RELEASED") != 1) { + throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); + } + lock.setStatus("RELEASED"); + lock.setReleasedAt(LocalDateTime.now()); + return WarehouseResourceLockResponse.from(lock); + } + + @Transactional + public WarehouseResourceLockResponse scrap(long customerId, long lockId) { + WarehouseResourceLockEntity lock = requireLock(customerId, lockId); + if ("SCRAPPED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); + requireActive(lock); + boolean virtualLocation = isVirtualLockLocation(lock); + int palletResult = virtualLocation ? repository.scrapPalletAtVirtualLocation(lock) : repository.scrapPallet(lock); + if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1) + || repository.close(lock, "SCRAPPED") != 1) { + throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); + } + lock.setStatus("SCRAPPED"); + lock.setReleasedAt(LocalDateTime.now()); + return WarehouseResourceLockResponse.from(lock); + } + + @Transactional + public WarehousePalletScrapResponse scrapByPallet(long customerId, long palletId) { + WarehousePalletEntity pallet = repository.findPallet(customerId, palletId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "Pallet not found"); + if ("SCRAPPED".equals(pallet.getStatus())) { + return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED"); + } + if (!"OCCUPIED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Only an OCCUPIED pallet can be scrapped after shipment"); + } + WarehouseResourceLockEntity lock = repository.findActiveByPallet(customerId, palletId); + if (lock != null) { + scrap(customerId, lock.getId()); + } else if (repository.scrapPalletWithoutLock(customerId, palletId) != 1) { + throw new BizException(ResultCode.CONFLICT, "Pallet state changed concurrently"); + } + return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED"); + } + + private WarehouseResourceLockResponse concurrentIdempotentOrConflict( + WarehouseResourceLockRequests.ReserveRequest request) { + WarehouseResourceLockEntity existing = repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (existing != null) return idempotentResult(existing, request); + throw new BizException(ResultCode.CONFLICT, "Pallet is no longer IDLE"); + } + + private WarehouseResourceLockResponse idempotentResult(WarehouseResourceLockEntity existing, + WarehouseResourceLockRequests.ReserveRequest request) { + if (existing.getWarehouseId() != request.warehouseId() + || existing.getPalletId() != request.palletId() + || existing.getLocationId() != request.locationId() + || !Objects.equals(existing.getBusinessType(), request.businessType()) + || !Objects.equals(existing.getBusinessId(), request.businessId())) { + throw new BizException(ResultCode.CONFLICT, "Reservation key was already used with different resources"); + } + return WarehouseResourceLockResponse.from(existing); + } + + private WarehouseResourceLockEntity requireLock(long customerId, long lockId) { + WarehouseResourceLockEntity lock = repository.find(customerId, lockId); + if (lock == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock not found"); + return lock; + } + + private static void requireActive(WarehouseResourceLockEntity lock) { + if (!"ACTIVE".equals(lock.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Resource lock is not ACTIVE"); + } + } + + + private boolean isVirtualLockLocation(WarehouseResourceLockEntity lock) { + WarehouseLocationEntity location = repository.findLocation(lock.getCustomerId(), lock.getWarehouseId(), lock.getLocationId()); + if (location == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock location not found"); + return isVirtual(location); + } + + private static boolean isVirtual(WarehouseLocationEntity location) { + return "VIRTUAL".equals(location.getLocationType()); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java new file mode 100644 index 000000000..d44e16759 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -0,0 +1,321 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; +import com.baiyue.warehouse.dto.WarehouseLocationResolveResponse; +import com.baiyue.warehouse.dto.SharedWarehouseReference; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; +import com.baiyue.warehouse.repository.WarehouseResourceRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; + +import java.util.Set; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import org.springframework.util.StringUtils; + +@Service +public class WarehouseResourceService { + public static final long PUBLIC_RESOURCE_SCOPE = 0L; + private static final Set LOCATION_STATUSES = Set.of("ACTIVE", "IDLE", "OCCUPIED", "INACTIVE"); + private static final Set PALLET_STATUSES = Set.of("IDLE", "OCCUPIED", "SCRAPPED"); + + @Resource private WarehouseResourceRepository repository; + @Resource private DomesticWarehouseDirectory warehouseDirectory; + + @Transactional + public Long createLocation(long customerId, long warehouseId, LocationRequests.SaveRequest request) { + requireWarehouse(customerId, warehouseId); + validateLocation(request, customerId, warehouseId, null); + WarehouseLocationEntity entity = locationEntity(customerId, warehouseId, request); + repository.insertLocation(entity); + return entity.getId(); + } + + @Transactional + public void updateLocation(long customerId, long warehouseId, long id, LocationRequests.SaveRequest request) { + WarehouseLocationEntity existing = requireLocation(customerId, warehouseId, id); + if ("VIRTUAL".equals(existing.getLocationType()) + && (!"VIRTUAL".equals(request.locationType()) || !"ACTIVE".equals(request.status()) + || !existing.getLocationCode().equals(request.locationCode()))) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location must remain VIRTUAL and ACTIVE"); + } + validateLocation(request, customerId, warehouseId, id); + WarehouseLocationEntity entity = locationEntity(customerId, warehouseId, request); + entity.setId(existing.getId()); + repository.updateLocation(entity); + } + + public WarehouseLocationEntity location(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + return requireLocation(customerId, warehouseId, id); + } + + public PageResult locations(long customerId, long warehouseId, + LocationRequests.QueryRequest query) { + requireWarehouse(customerId, warehouseId); + return repository.pageLocations(customerId, warehouseId, query); + } + + @Transactional + public void deleteLocation(long customerId, long warehouseId, long id) { + WarehouseLocationEntity location = requireLocation(customerId, warehouseId, id); + if ("VIRTUAL".equals(location.getLocationType())) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location cannot be deleted"); + } + if (repository.locationHasPallets(customerId, warehouseId, id)) { + throw new BizException(ResultCode.CONFLICT, "库位仍绑定托盘,不能删除"); + } + repository.deleteLocation(id); + } + + @Transactional + public Long createPallet(long customerId, long warehouseId, PalletRequests.SaveRequest request) { + SharedWarehouseReference warehouse = requireWarehouseReference(warehouseId); + validatePalletStatus(request.status()); + Long palletId = IdWorker.getId(); + String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim() + : generatedPalletCode(warehouse.warehouseCode(), palletId); + Long locationId = request.locationId(); + if (locationId == null) { + locationId = ensureDefaultVirtualLocation(customerId, warehouseId).getId(); + } + validatePallet(request, palletCode, locationId, customerId, warehouseId, null); + WarehousePalletEntity entity = palletEntity(customerId, warehouseId, request, palletCode, locationId); + entity.setId(palletId); + repository.insertPallet(entity); + return entity.getId(); + } + + @Transactional + public WarehousePalletEntity ensureDropshipPallet(long customerId, long warehouseId, String businessId) { + WarehousePalletBindingEntity existing = repository.findPalletBinding(customerId, warehouseId, "DROPSHIP", businessId); + if (existing != null) return requirePallet(customerId, warehouseId, existing.getPalletId()); + Long palletId = createPallet(customerId, warehouseId, + new PalletRequests.SaveRequest(null, "DROPSHIP", null, java.math.BigDecimal.ZERO, "IDLE", null, + "Default dropship pallet for " + businessId)); + WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity(); + binding.setId(IdWorker.getId()); binding.setCustomerId(customerId); binding.setWarehouseId(warehouseId); + binding.setBusinessType("DROPSHIP"); binding.setBusinessId(businessId); binding.setPalletId(palletId); + binding.setCreatedAt(java.time.LocalDateTime.now()); + try { + repository.insertPalletBinding(binding); + return requirePallet(customerId, warehouseId, palletId); + } catch (org.springframework.dao.DuplicateKeyException duplicate) { + WarehousePalletBindingEntity winner = repository.findPalletBinding(customerId, warehouseId, "DROPSHIP", businessId); + if (winner == null) throw duplicate; + return requirePallet(customerId, warehouseId, winner.getPalletId()); + } + } + + /** + * Returns the warehouse-scoped virtual location used only as a pallet/inventory reference. + * It is deliberately never made IDLE/OCCUPIED by resource locking. + */ + @Transactional + public WarehouseLocationEntity ensureDefaultVirtualLocation(long customerId, long warehouseId) { + SharedWarehouseReference warehouse = requireWarehouseReference(warehouseId); + String locationCode = defaultLocationCode(warehouse.warehouseCode()); + WarehouseLocationEntity existing = repository.findLocationByCode(customerId, warehouseId, locationCode); + if (existing != null) { + if (!"VIRTUAL".equals(existing.getLocationType()) || !"ACTIVE".equals(existing.getStatus())) { + throw new BizException(ResultCode.CONFLICT, + "Default virtual location code is occupied by a non-active virtual location"); + } + return existing; + } + + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(IdWorker.getId()); + location.setCustomerId(customerId); + location.setWarehouseId(warehouseId); + location.setZoneCode("DEFAULT"); + location.setLocationCode(locationCode); + location.setLocationType("VIRTUAL"); + location.setMaxLoadKg(new java.math.BigDecimal("999999999.000")); + location.setStatus("ACTIVE"); + location.setRemark("System default virtual location"); + repository.insertDefaultVirtualLocationIgnore(location); + + WarehouseLocationEntity resolved = repository.findLocationByCode(customerId, warehouseId, locationCode); + if (resolved == null) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location could not be created"); + } + return resolved; + } + + @Transactional + public void updatePallet(long customerId, long warehouseId, long id, PalletRequests.SaveRequest request) { + WarehousePalletEntity existing = requirePallet(customerId, warehouseId, id); + if (request.version() == null || !request.version().equals(existing.getVersion())) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请刷新后重试"); + } + if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) { + throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused"); + } + String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim() + : existing.getPalletCode(); + Long locationId = request.locationId() == null ? existing.getLocationId() : request.locationId(); + validatePallet(request, palletCode, locationId, customerId, warehouseId, id); + WarehousePalletEntity entity = palletEntity(customerId, warehouseId, request, palletCode, locationId); + entity.setId(existing.getId()); + entity.setVersion(request.version()); + if (repository.updatePallet(entity) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请刷新后重试"); + } + } + + public WarehousePalletEntity pallet(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + return requirePallet(customerId, warehouseId, id); + } + + public WarehousePalletResolveResponse resolvePublicPallet(String palletCode) { + if (!StringUtils.hasText(palletCode)) { + throw new BizException(ResultCode.BAD_REQUEST, "托盘码不能为空"); + } + var matches = repository.findPublicPalletsByCode(palletCode.trim()); + if (matches.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "托盘码不存在"); + if (matches.size() != 1) throw new BizException(ResultCode.CONFLICT, "托盘码在多个仓库重复,无法解析"); + WarehousePalletEntity pallet = matches.get(0); + if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位"); + if ("SCRAPPED".equals(pallet.getStatus())) throw new BizException(ResultCode.CONFLICT, "托盘已报废"); + return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(), + pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(), + pallet.getVersion() == null ? 0 : pallet.getVersion()); + } + + public WarehouseLocationResolveResponse resolvePublicLocation(long warehouseId, String locationCode) { + if (!StringUtils.hasText(locationCode)) throw new BizException(ResultCode.BAD_REQUEST, "库位码不能为空"); + WarehouseLocationEntity location = repository.findLocationByCode(PUBLIC_RESOURCE_SCOPE, warehouseId, + locationCode.trim()); + if (location == null) throw new BizException(ResultCode.NOT_FOUND, "库位码不存在或不属于当前仓库"); + return locationReference(location); + } + + public WarehouseLocationResolveResponse resolvePublicLocation(long warehouseId, long locationId) { + WarehouseLocationEntity location = repository.findLocation(PUBLIC_RESOURCE_SCOPE, warehouseId, locationId); + if (location == null) throw new BizException(ResultCode.NOT_FOUND, "库位不存在或不属于当前仓库"); + return locationReference(location); + } + + private static WarehouseLocationResolveResponse locationReference(WarehouseLocationEntity location) { + return new WarehouseLocationResolveResponse(location.getId(), location.getLocationCode(), + location.getLocationType(), location.getMaxLoadKg(), location.getStatus()); + } + + public PageResult pallets(long customerId, long warehouseId, + PalletRequests.QueryRequest query) { + requireWarehouse(customerId, warehouseId); + return repository.pagePallets(customerId, warehouseId, query); + } + + @Transactional + public void deletePallet(long customerId, long warehouseId, long id) { + WarehousePalletEntity pallet = requirePallet(customerId, warehouseId, id); + if ("OCCUPIED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "占用中的托盘不能删除"); + } + if ("SCRAPPED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Scrapped pallet must be retained and cannot be reused"); + } + repository.deletePallet(id); + } + + private void requireWarehouse(long customerId, long warehouseId) { + warehouseDirectory.required(warehouseId); + } + + private SharedWarehouseReference requireWarehouseReference(long warehouseId) { + return warehouseDirectory.required(warehouseId); + } + + private WarehouseLocationEntity requireLocation(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + WarehouseLocationEntity entity = repository.findLocation(customerId, warehouseId, id); + if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下库位不存在"); + return entity; + } + + private WarehousePalletEntity requirePallet(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + WarehousePalletEntity entity = repository.findPallet(customerId, warehouseId, id); + if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下托盘不存在"); + return entity; + } + + private void validateLocation(LocationRequests.SaveRequest request, long customerId, + long warehouseId, Long excludedId) { + if (!LOCATION_STATUSES.contains(request.status())) { + throw new BizException("库位状态仅支持 ACTIVE、IDLE、OCCUPIED、INACTIVE"); + } + if (repository.locationCodeExists(customerId, warehouseId, request.locationCode(), excludedId)) { + throw new BizException(ResultCode.CONFLICT, "当前仓库下库位编码已存在"); + } + } + + private void validatePallet(PalletRequests.SaveRequest request, String palletCode, Long locationId, + long customerId, long warehouseId, Long excludedId) { + validatePalletStatus(request.status()); + if (repository.palletCodeExists(customerId, warehouseId, palletCode, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "当前仓库下托盘编码已存在"); + } + if (locationId != null) { + requireLocation(customerId, warehouseId, locationId); + } + } + + private void validatePalletStatus(String status) { + if (!PALLET_STATUSES.contains(status)) { + throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED"); + } + } + + private WarehouseLocationEntity locationEntity(long customerId, long warehouseId, + LocationRequests.SaveRequest request) { + WarehouseLocationEntity entity = new WarehouseLocationEntity(); + entity.setCustomerId(customerId); + entity.setWarehouseId(warehouseId); + entity.setZoneCode(request.zoneCode()); + entity.setLocationCode(request.locationCode()); + entity.setLocationType(request.locationType()); + entity.setMaxLoadKg(request.maxLoadKg()); + entity.setStatus(request.status()); + entity.setRemark(request.remark()); + return entity; + } + + private WarehousePalletEntity palletEntity(long customerId, long warehouseId, + PalletRequests.SaveRequest request, + String palletCode, Long locationId) { + WarehousePalletEntity entity = new WarehousePalletEntity(); + entity.setCustomerId(customerId); + entity.setWarehouseId(warehouseId); + entity.setLocationId(locationId); + entity.setPalletCode(palletCode); + entity.setPalletType(request.palletType()); + entity.setCapacityCbm(request.capacityCbm()); + entity.setUnitPrice(request.unitPrice()); + entity.setStatus(request.status()); + entity.setRemark(request.remark()); + entity.setFbaWarehouseCode(request.fbaWarehouseCode()); + return entity; + } + + private static String generatedPalletCode(String warehouseCode, long palletId) { + return "PLT-" + warehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + Long.toUnsignedString(palletId); + } + + private static String defaultLocationCode(String warehouseCode) { + return "LOC-" + warehouseCode + "-DEFAULT"; + } +} diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml new file mode 100644 index 000000000..d7387e408 --- /dev/null +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -0,0 +1,52 @@ +server: + port: ${WAREHOUSE_PORT:9305} +spring: + sql: + init: + mode: always + encoding: UTF-8 + schema-locations: classpath:db/warehouse.sql + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + 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 + cloud: + nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + enabled: ${NACOS_DISCOVERY_ENABLED:true} + register-enabled: ${NACOS_REGISTER_ENABLED:true} + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:public} + config: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + namespace: ${NACOS_NAMESPACE:public} + 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} +management.endpoints.web.exposure.include: health,info,prometheus + +baiyue: + clients: + domestic-warehouse: + 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} + security: + jwt-mode: jwks + session-mode: claims + 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/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql new file mode 100644 index 000000000..fd952af77 --- /dev/null +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -0,0 +1,144 @@ +-- This script is run through warehouse-overseas-service's datasource only. +-- The datasource guard requires the selected schema to be baiyue_lastlag_warehouse; +-- database creation and USE are intentionally forbidden here so a miswired +-- service account cannot create or switch schemas during startup. + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS wh_location ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_location_code (customer_id, warehouse_id, location_code, deleted), + KEY idx_location_query (customer_id, warehouse_id, zone_code, status, deleted) +) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; + +CREATE TABLE IF NOT EXISTS wh_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + location_id BIGINT UNSIGNED DEFAULT NULL COMMENT '同仓库内绑定的库位ID,可为空', + pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + fba_warehouse_code VARCHAR(128) DEFAULT NULL COMMENT '所属FBA仓编码', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_code (customer_id, warehouse_id, pallet_code, deleted), + KEY idx_pallet_query (customer_id, warehouse_id, status, deleted), + KEY idx_pallet_location (customer_id, warehouse_id, location_id, deleted) +) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='wh_pallet' AND column_name='fba_warehouse_code'), + 'SELECT 1', + 'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark')); +PREPARE pallet_fba_warehouse_stmt FROM @ddl; +EXECUTE pallet_fba_warehouse_stmt; +DEALLOCATE PREPARE pallet_fba_warehouse_stmt; + +CREATE TABLE IF NOT EXISTS wh_resource_lock ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', + location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', + business_type VARCHAR(50) NOT NULL COMMENT '业务类型', + business_id VARCHAR(100) NOT NULL COMMENT '业务记录ID', + reservation_key VARCHAR(160) NOT NULL COMMENT '资源预留幂等键', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_resource_lock_reservation (customer_id, reservation_key), + KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status), + KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status), + KEY idx_resource_lock_business (customer_id, business_type, business_id) +) ENGINE=InnoDB COMMENT='仓储资源锁定记录'; + +-- warehouse_id references the domestic warehouse-service authority. This overseas database does +-- not duplicate warehouse master, address or capability tables and has no cross-database FK. + +-- 修复既有数据库中因历史脚本编码错误或英文初始化产生的表与字段注释。 +ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; +ALTER TABLE wh_location + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +CREATE TABLE IF NOT EXISTS wh_pallet_business_binding ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id BIGINT NOT NULL COMMENT '客户租户ID', warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_id VARCHAR(128) NOT NULL COMMENT '绑定业务记录ID', + pallet_id BIGINT NOT NULL COMMENT '托盘ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_binding (customer_id,warehouse_id,business_type,business_id), + UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='托盘业务绑定关系'; + +CREATE TABLE IF NOT EXISTS wh_pallet_operation ( + id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', + request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', + before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', + before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', + operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', + reason VARCHAR(500) 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 (id), UNIQUE KEY uk_wh_pallet_operation_key (operation_key), + KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录'; + +ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; +ALTER TABLE wh_pallet + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT '所属FBA仓编码', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; 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 new file mode 100644 index 000000000..2f2ef9751 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java @@ -0,0 +1,44 @@ +package com.baiyue.warehouse.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +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") + .doesNotContain("baiyue_warehouse_overseas"); + } + + @Test + void acceptsDedicatedOverseasWarehouseDatabase() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.datasource.url", + "jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?serverTimezone=Asia/Shanghai"); + + assertThatCode(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment)) + .doesNotThrowAnyException(); + } + + @Test + void rejectsAnyOtherDatabaseBeforeSchemaInitialization() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.datasource.url", "jdbc:mysql://127.0.0.1:3306/baiyue_warehouse"); + + assertThatThrownBy(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("baiyue_lastlag_warehouse"); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java new file mode 100644 index 000000000..1da6a1cb5 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java @@ -0,0 +1,78 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.service.WarehouseResourceService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WarehouseResourceControllerTest { + private final WarehouseResourceService service = mock(WarehouseResourceService.class); + + @Test + void merchantTenantAlwaysComesFromAuthenticatedUser() { + MerchantWarehouseResourceController controller = merchantController(); + AuthenticatedUser merchant = user(10001L, "MERCHANT"); + WarehouseLocationEntity expected = new WarehouseLocationEntity(); + when(service.location(10001L, 20001L, 30001L)).thenReturn(expected); + + controller.location(merchant, 20001L, 30001L); + + verify(service).location(10001L, 20001L, 30001L); + } + + @Test + void platformApiRejectsNonPlatformIdentity() { + PlatformWarehouseResourceController controller = platformController(); + + assertThatThrownBy(() -> controller.location(user(10001L, "MERCHANT"), + 20001L, 30001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("平台端"); + } + + @Test + void platformApiUsesPublicResourceScope() { + PlatformWarehouseResourceController controller = platformController(); + WarehouseLocationEntity expected = new WarehouseLocationEntity(); + when(service.location(0L, 20001L, 30001L)).thenReturn(expected); + + controller.location(user(0L, "PLATFORM"), 20001L, 30001L); + + verify(service).location(0L, 20001L, 30001L); + } + + @Test + void platformApiRejectsPlatformAppWithCustomerScope() { + PlatformWarehouseResourceController controller = platformController(); + + assertThatThrownBy(() -> controller.location(user(10001L, "PLATFORM"), 20001L, 30001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("平台端"); + } + + private static AuthenticatedUser user(long customerId, String appCode) { + return new AuthenticatedUser(1L, customerId, customerId, 1L, + "tester", appCode, "WEB", Set.of()); + } + + private MerchantWarehouseResourceController merchantController() { + MerchantWarehouseResourceController controller = new MerchantWarehouseResourceController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } + + private PlatformWarehouseResourceController platformController() { + PlatformWarehouseResourceController controller = new PlatformWarehouseResourceController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } +} 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 new file mode 100644 index 000000000..65588f077 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.warehouse.controller; + +import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WarehouseResourcePermissionContractTest { + private static final String PREFIX = "platform:overseas-warehouse:"; + + @Test + void locationAndPalletEndpointsUseOnlyCanonicalAuthorities() { + Map methods = Arrays.stream(PlatformWarehouseResourceController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(PreAuthorize.class)) + .collect(Collectors.toMap(Method::getName, method -> method)); + + assertPermission(methods.get("locations"), PREFIX + "location:list"); + assertPermission(methods.get("location"), PREFIX + "location:list"); + assertPermission(methods.get("createLocation"), PREFIX + "location:create"); + assertPermission(methods.get("updateLocation"), PREFIX + "location:edit"); + assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete"); + assertPermission(methods.get("pallets"), PREFIX + "pallet:list"); + assertPermission(methods.get("pallet"), PREFIX + "pallet:list"); + assertPermission(methods.get("createPallet"), PREFIX + "pallet:create"); + assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit"); + assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete"); + } + + private static void assertPermission(Method method, String authority) { + PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); + Operation operation = method.getAnnotation(Operation.class); + assertEquals("hasAuthority('" + authority + "')", preAuthorize.value()); + assertEquals("Required authority: " + authority, operation.description()); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java new file mode 100644 index 000000000..06f4c4d57 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.mapper; + +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehousePalletTransitionMapperContractTest { + + @Test + void annotationSqlUsesNativeComparisonOperatorsInsteadOfXmlEntities() throws Exception { + Method locationQuery = WarehousePalletTransitionMapper.class.getMethod( + "countActiveLocation", long.class, long.class); + String selectSql = String.join(" ", locationQuery.getAnnotation(Select.class).value()); + + Method transition = Arrays.stream(WarehousePalletTransitionMapper.class.getMethods()) + .filter(method -> method.getName().equals("transition")) + .findFirst() + .orElseThrow(); + String updateSql = String.join(" ", transition.getAnnotation(Update.class).value()); + + assertTrue(selectSql.contains("status<>'INACTIVE'")); + assertTrue(updateSql.contains("status<>'SCRAPPED'")); + assertFalse(selectSql.contains("<") || selectSql.contains(">")); + assertFalse(updateSql.contains("<") || updateSql.contains(">")); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java new file mode 100644 index 000000000..040873e9e --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java @@ -0,0 +1,101 @@ +package com.baiyue.warehouse.schema; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SchemaChineseCommentContractTest { + + private static final Pattern TABLE = Pattern.compile( + "(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(wh_[a-z0-9_]+)" + + "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;"); + private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]"); + + @Test + void allWarehouseTablesAndColumnsHaveChineseCommentsAndMigrationCoverage() throws Exception { + String baseline = Files.readString( + Path.of("src", "main", "resources", "db", "warehouse.sql"), StandardCharsets.UTF_8); + String incremental = Files.readString( + Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); + + List tables = parseAndAssert(baseline, "仓储基线 SQL"); + assertEquals(5, tables.size(), "海外仓资源表数量变化时必须同步表索引和备注迁移"); + assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_warehouse (")); + assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_warehouse_address (")); + assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_service_capability (")); + parseAndAssert(incremental, "统一增量 SQL"); + + int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments"); + assertTrue(marker >= 0, "统一增量 SQL 缺少 025 中文备注迁移"); + String migration = incremental.substring(marker); + for (TableDefinition table : tables) { + assertTrue(migration.contains("table_name='" + table.name() + "'"), + "025 备注迁移缺少表: " + table.name()); + for (String column : table.columns()) { + assertTrue(migration.contains("table_name='" + table.name() + + "' AND column_name='" + column + "'"), + "025 备注迁移缺少字段: " + table.name() + "." + column); + } + } + } + + private static List parseAndAssert(String sql, String source) { + List tables = new ArrayList<>(); + Matcher matcher = TABLE.matcher(sql); + while (matcher.find()) { + String table = matcher.group(1); + assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table); + List columns = new ArrayList<>(); + for (String definition : splitTopLevel(matcher.group(2))) { + String normalized = definition.stripLeading().toUpperCase(); + if (normalized.matches("^(PRIMARY|UNIQUE|KEY|CONSTRAINT|FOREIGN|CHECK)\\b.*")) { + continue; + } + String column = definition.stripLeading().split("\\s+", 2)[0].replace("`", ""); + columns.add(column); + assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'") + .matcher(definition).find(), + source + " 字段备注必须包含中文: " + table + "." + column); + } + assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table); + tables.add(new TableDefinition(table, columns)); + } + assertFalse(tables.isEmpty(), source + " 未解析到仓储表"); + return tables; + } + + private static List splitTopLevel(String body) { + List definitions = new ArrayList<>(); + int start = 0; + int parentheses = 0; + boolean quoted = false; + for (int i = 0; i < body.length(); i++) { + char current = body.charAt(i); + if (current == '\'' && (i == 0 || body.charAt(i - 1) != '\\')) { + quoted = !quoted; + } else if (!quoted && current == '(') { + parentheses++; + } else if (!quoted && current == ')') { + parentheses--; + } else if (!quoted && parentheses == 0 && current == ',') { + definitions.add(body.substring(start, i).trim()); + start = i + 1; + } + } + definitions.add(body.substring(start).trim()); + return definitions.stream().filter(value -> !value.isEmpty()).toList(); + } + + private record TableDefinition(String name, List columns) { + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java new file mode 100644 index 000000000..639f8cfb7 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.schema; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehouseResourceLockSchemaTest { + @Test + void schemaContainsIdempotentResourceLockContract() throws IOException { + String sql; + try (var stream = getClass().getResourceAsStream("/db/warehouse.sql")) { + assertThat(stream).isNotNull(); + sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_resource_lock"); + assertThat(sql).contains("UNIQUE KEY uk_resource_lock_reservation (customer_id, reservation_key)"); + assertThat(sql).contains("KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status)"); + assertThat(sql).contains("KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status)"); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseUnifiedIncrementalContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseUnifiedIncrementalContractTest.java new file mode 100644 index 000000000..8c5aee650 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseUnifiedIncrementalContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.schema; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehouseUnifiedIncrementalContractTest { + + @Test + void overseasUnifiedEntryUpgradesAndValidatesPalletFbaWarehouseCode() throws Exception { + String sql = Files.readString( + Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + + assertTrue(sql.contains("USE baiyue_lastlag_warehouse")); + assertTrue(sql.contains("DROP TABLE IF EXISTS wh_service_capability")); + assertTrue(sql.contains("DROP TABLE IF EXISTS wh_warehouse_address")); + assertTrue(sql.contains("DROP TABLE IF EXISTS wh_warehouse")); + assertTrue(sql.contains("ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL")); + assertTrue(sql.contains("@warehouse_pallet_fba_ready")); + assertTrue(sql.contains("wh_pallet.fba_warehouse_code migration is incomplete")); + assertTrue(sql.contains("USE baiyue_lastleg")); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java new file mode 100644 index 000000000..0f957134e --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.security; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InternalServiceTokenGuardTest { + private final InternalServiceTokenGuard guard = + new InternalServiceTokenGuard("phase1-local-internal-token-at-least-32-bytes"); + + @Test + void acceptsConfiguredToken() { + assertThatCode(() -> guard.verify("phase1-local-internal-token-at-least-32-bytes")) + .doesNotThrowAnyException(); + } + + @Test + void rejectsMissingOrWrongToken() { + assertUnauthorized(null); + assertUnauthorized("wrong"); + } + + private void assertUnauthorized(String token) { + assertThatThrownBy(() -> guard.verify(token)) + .isInstanceOfSatisfying(ResponseStatusException.class, + error -> assertThat(error.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/DomesticWarehouseDirectoryTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/DomesticWarehouseDirectoryTest.java new file mode 100644 index 000000000..09b71f950 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/DomesticWarehouseDirectoryTest.java @@ -0,0 +1,63 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.client.DomesticWarehouseDirectoryClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DomesticWarehouseDirectoryTest { + @Mock DomesticWarehouseDirectoryClient client; + private DomesticWarehouseDirectory directory; + + @BeforeEach + void setUp() { + directory = new DomesticWarehouseDirectory(); + ReflectionTestUtils.setField(directory, "client", client); + ReflectionTestUtils.setField(directory, "internalToken", "internal-token"); + ReflectionTestUtils.setField(directory, "ttl", Duration.ofMinutes(30)); + } + + @Test + void cachesPositiveWarehouseForThirtyMinutes() { + var warehouse = new DomesticWarehouseDirectoryClient.WarehouseReference( + 92L, "US-LAX-01", "Los Angeles", "US", "ACTIVE"); + when(client.activeSharedWarehouse("internal-token", 92L)) + .thenReturn(ApiResponse.success(warehouse)); + + assertThat(directory.required(92L)).isEqualTo(directory.required(92L)); + verify(client, times(1)).activeSharedWarehouse("internal-token", 92L); + } + + @Test + void doesNotCacheMissingWarehouse() { + when(client.activeSharedWarehouse("internal-token", 93L)) + .thenReturn(ApiResponse.success(null)); + + assertThatThrownBy(() -> directory.required(93L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> directory.required(93L)).isInstanceOf(BizException.class); + verify(client, times(2)).activeSharedWarehouse("internal-token", 93L); + } + + @Test + void emptyOptionDirectoryDoesNotCreatePositiveCacheEntries() { + when(client.activeSharedWarehouses("internal-token")) + .thenReturn(ApiResponse.success(List.of())); + + assertThat(directory.options()).isEmpty(); + } +} 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 new file mode 100644 index 000000000..5f998e23c --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java @@ -0,0 +1,91 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class WarehousePalletTransitionServiceTest { + @Mock private WarehousePalletTransitionMapper mapper; + private WarehousePalletTransitionService service; + + @BeforeEach void setUp() { + service = new WarehousePalletTransitionService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + } + + @Test void appliesVersionedPublicPalletTransitionAndPersistsCompensationReceipt() { + WarehousePalletEntity pallet = pallet("P-1", "IDLE", 4); + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet); + when(mapper.countActiveLocation(88L, 20L)).thenReturn(1L); + when(mapper.transition(88L, 10L, "OCCUPIED", 20L, 4)).thenReturn(1); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", "assemble")); + + assertEquals("APPLIED", response.operationStatus()); + assertEquals(5, response.afterVersion()); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletOperationEntity.class); + verify(mapper).insert(captor.capture()); + assertEquals("IDLE", captor.getValue().getBeforeStatus()); + assertEquals("OCCUPIED", captor.getValue().getAfterStatus()); + assertEquals(4, captor.getValue().getBeforeVersion()); + } + + @Test void exactReplayDoesNotMutatePhysicalPalletAgain() { + WarehousePalletOperationEntity existing = operation(); + when(mapper.findOperation("operation-1")).thenReturn(existing); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", "any changed reason")); + + assertTrue(response.replayed()); + verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); + } + + @Test void compensationRequiresExactAppliedVersionAndRestoresBeforeState() { + WarehousePalletOperationEntity existing = operation(); + when(mapper.lockOperation("operation-1")).thenReturn(existing); + when(mapper.transition(88L, 10L, "IDLE", 11L, 5)).thenReturn(1); + + var response = service.compensate(10L, "operation-1", new PalletTransitionRequests.Compensate(88L)); + + assertEquals("COMPENSATED", response.operationStatus()); + verify(mapper).updateById(existing); + } + + @Test void staleVersionIsRejectedWithoutPhysicalMutation() { + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "IDLE", 5)); + assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", null))); + verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); + } + + private static WarehousePalletEntity pallet(String code, String status, int version) { + WarehousePalletEntity value = new WarehousePalletEntity(); value.setId(10L); value.setCustomerId(0L); + value.setWarehouseId(88L); value.setLocationId(11L); value.setPalletCode(code); + value.setStatus(status); value.setVersion(version); return value; + } + private static WarehousePalletOperationEntity operation() { + WarehousePalletOperationEntity value = new WarehousePalletOperationEntity(); value.setId(1L); + value.setOperationKey("operation-1"); + value.setRequestHash("f17b4386e6fdecbaab1d066ae4fefa47dba0687ff27361b8f92ef097d4e8072e"); + value.setWarehouseId(88L); value.setPalletId(10L); value.setPalletCode("P-1"); + value.setBeforeStatus("IDLE"); value.setAfterStatus("OCCUPIED"); value.setBeforeLocationId(11L); + value.setAfterLocationId(20L); value.setBeforeVersion(4); value.setAfterVersion(5); + value.setOperationStatus("APPLIED"); return value; + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java new file mode 100644 index 000000000..a5420b4ba --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java @@ -0,0 +1,263 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.repository.WarehouseResourceLockRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseResourceLockServiceTest { + private static final long TENANT = 101L; + private static final long WAREHOUSE = 201L; + private static final long PALLET = 301L; + private static final long LOCATION = 401L; + + @Mock WarehouseResourceLockRepository repository; + private WarehouseResourceLockService service; + + @BeforeEach + void setUp() { + service = new WarehouseResourceLockService(); + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test + void atomicallyOccupiesIdlePalletAndLocation() { + var request = request("reserve-1"); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(1); + doAnswer(call -> { + ((WarehouseResourceLockEntity) call.getArgument(0)).setId(501L); + return null; + }).when(repository).insert(any()); + + var result = service.reserve(request); + + assertThat(result.lockId()).isEqualTo(501L); + assertThat(result.status()).isEqualTo("ACTIVE"); + ArgumentCaptor lock = ArgumentCaptor.forClass(WarehouseResourceLockEntity.class); + verify(repository).insert(lock.capture()); + assertThat(lock.getValue().getReservationKey()).isEqualTo("reserve-1"); + assertThat(lock.getValue().getPalletId()).isEqualTo(PALLET); + assertThat(lock.getValue().getLocationId()).isEqualTo(LOCATION); + } + + @Test + void duplicateReservationReturnsExistingLockWithoutMutatingResources() { + WarehouseResourceLockEntity existing = activeLock(); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing); + + var result = service.reserve(request("reserve-1")); + + assertThat(result.lockId()).isEqualTo(existing.getId()); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + verify(repository, never()).insert(any()); + } + + @Test + void concurrentDuplicateReturnsExistingAfterResourcesBecomeOccupied() { + WarehouseResourceLockEntity existing = activeLock(); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(null, existing); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); + + var result = service.reserve(request("reserve-1")); + + assertThat(result.lockId()).isEqualTo(existing.getId()); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + } + + @Test + void reservationKeyCannotBeReusedForDifferentResources() { + WarehouseResourceLockEntity existing = activeLock(); + existing.setPalletId(999L); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("different resources"); + } + + @Test + void rejectsResourceOutsideTenantWarehouse() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(null); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("tenant warehouse"); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + } + + @Test + void rejectsNonIdleResources() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("Only IDLE"); + } + + @Test + void releaseRestoresBothResourcesAndClosesLock() { + WarehouseResourceLockEntity lock = activeLock(); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); + when(repository.releasePallet(lock)).thenReturn(1); + when(repository.releaseLocation(lock)).thenReturn(1); + when(repository.close(lock, "RELEASED")).thenReturn(1); + + var result = service.release(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("RELEASED"); + verify(repository).releasePallet(lock); + verify(repository).releaseLocation(lock); + } + + @Test + void scrapDiscardsPalletAndReleasesLocation() { + WarehouseResourceLockEntity lock = activeLock(); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); + when(repository.scrapPallet(lock)).thenReturn(1); + when(repository.releaseLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrap(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + verify(repository).scrapPallet(lock); + verify(repository).releaseLocation(lock); + } + + @Test + void concurrentLocationClaimFailsWholeOperation() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(0); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("no longer IDLE"); + verify(repository, never()).insert(any()); + } + + @Test + void virtualLocationIsNeverOccupiedOrReleased() { + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + doAnswer(call -> { + ((WarehouseResourceLockEntity) call.getArgument(0)).setId(502L); + return null; + }).when(repository).insert(any()); + + service.reserve(request("virtual-reserve")); + + verify(repository, never()).occupyLocation(TENANT, WAREHOUSE, LOCATION); + } + + @Test + void scrappingAtVirtualLocationKeepsLocationUntouched() { + WarehouseResourceLockEntity lock = activeLock(); + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrap(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + verify(repository).scrapPalletAtVirtualLocation(lock); + verify(repository, never()).releaseLocation(lock); + } + + @Test + void scrapByPalletClosesItsActiveLockAndKeepsLocationReference() { + WarehousePalletEntity pallet = pallet("OCCUPIED"); + pallet.setWarehouseId(WAREHOUSE); + pallet.setLocationId(LOCATION); + WarehouseResourceLockEntity lock = activeLock(); + when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet); + when(repository.findActiveByPallet(TENANT, PALLET)).thenReturn(lock); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrapByPallet(TENANT, PALLET); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + assertThat(result.locationId()).isEqualTo(LOCATION); + verify(repository).scrapPalletAtVirtualLocation(lock); + } + + @Test + void scrapByPalletIsIdempotentWhenAlreadyScrapped() { + WarehousePalletEntity pallet = pallet("SCRAPPED"); + pallet.setWarehouseId(WAREHOUSE); + pallet.setLocationId(LOCATION); + when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet); + + assertThat(service.scrapByPallet(TENANT, PALLET).status()).isEqualTo("SCRAPPED"); + verify(repository, never()).findActiveByPallet(TENANT, PALLET); + } + + private static WarehouseResourceLockRequests.ReserveRequest request(String key) { + return new WarehouseResourceLockRequests.ReserveRequest(TENANT, WAREHOUSE, PALLET, LOCATION, + "INBOUND_PLAN_PALLET", "plan-pallet-1", key); + } + + private static WarehousePalletEntity pallet(String status) { + WarehousePalletEntity entity = new WarehousePalletEntity(); + entity.setStatus(status); + return entity; + } + + private static WarehouseLocationEntity location(String status) { + WarehouseLocationEntity entity = new WarehouseLocationEntity(); + entity.setStatus(status); + return entity; + } + + private static WarehouseResourceLockEntity activeLock() { + WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity(); + lock.setId(501L); + lock.setCustomerId(TENANT); + lock.setWarehouseId(WAREHOUSE); + lock.setPalletId(PALLET); + lock.setLocationId(LOCATION); + lock.setBusinessType("INBOUND_PLAN_PALLET"); + lock.setBusinessId("plan-pallet-1"); + lock.setReservationKey("reserve-1"); + lock.setStatus("ACTIVE"); + return lock; + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java new file mode 100644 index 000000000..c56bcbd9e --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -0,0 +1,336 @@ +package com.baiyue.warehouse.service; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.dto.SharedWarehouseReference; +import com.baiyue.warehouse.repository.WarehouseResourceRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseResourceServiceTest { + private static final long TENANT = 10001L; + private static final long WAREHOUSE = 20001L; + + @Mock + private WarehouseResourceRepository repository; + @Mock + private DomesticWarehouseDirectory warehouseDirectory; + + private WarehouseResourceService service; + + @BeforeEach + void setUp() { + service = new WarehouseResourceService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); + org.mockito.Mockito.lenient().when(warehouseDirectory.required(WAREHOUSE)) + .thenReturn(warehouse()); + } + + @Test + void resourceIdsUseMybatisSnowflakeAssignment() throws NoSuchFieldException { + assertThat(WarehouseLocationEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type()) + .isEqualTo(IdType.ASSIGN_ID); + assertThat(WarehousePalletEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type()) + .isEqualTo(IdType.ASSIGN_ID); + } + + @Test + void resolvesOnlyPublicLocationInsideBoundWarehouse() { + WarehouseLocationEntity location = new WarehouseLocationEntity(); location.setId(30001L); + location.setWarehouseId(WAREHOUSE); location.setCustomerId(0L); location.setLocationCode("A-01"); + location.setLocationType("STORAGE"); location.setMaxLoadKg(new BigDecimal("1000")); location.setStatus("IDLE"); + when(repository.findLocationByCode(0L, WAREHOUSE, "A-01")).thenReturn(location); + + var result = service.resolvePublicLocation(WAREHOUSE, " A-01 "); + + assertThat(result.locationId()).isEqualTo(30001L); + assertThat(result.locationCode()).isEqualTo("A-01"); + } + + @Test + void createsLocationWithSnowflakeAssignedByMapper() { + doAnswer(invocation -> { + ((WarehouseLocationEntity) invocation.getArgument(0)).setId(2089000000000000001L); + return null; + }).when(repository).insertLocation(org.mockito.ArgumentMatchers.any()); + + Long id = service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01")); + + assertThat(id).isEqualTo(2089000000000000001L); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehouseLocationEntity.class); + verify(repository).insertLocation(captor.capture()); + assertThat(captor.getValue().getCustomerId()).isEqualTo(TENANT); + assertThat(captor.getValue().getWarehouseId()).isEqualTo(WAREHOUSE); + } + + @Test + void platformPalletUsesPublicScopeAgainstAnyExistingWarehouse() { + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30001L); + when(repository.findLocation(0L, WAREHOUSE, 30001L)).thenReturn(location); + + service.createPallet(0L, WAREHOUSE, new PalletRequests.SaveRequest( + "PLT-PUBLIC", "STANDARD", null, BigDecimal.ONE, "IDLE", 30001L, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getCustomerId()).isZero(); + assertThat(captor.getValue().getWarehouseId()).isEqualTo(WAREHOUSE); + } + + @Test + void rejectsResourceAccessWhenWarehouseDoesNotBelongToTenant() { + when(warehouseDirectory.required(WAREHOUSE)).thenThrow(new BizException( + com.baiyue.common.core.api.ResultCode.NOT_FOUND, "仓库不存在")); + + assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("仓库不存在"); + verify(repository, never()).insertLocation(org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsDuplicateLocationCodeWithinWarehouse() { + when(repository.locationCodeExists(TENANT, WAREHOUSE, "A-01", null)).thenReturn(true); + + assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("库位编码已存在"); + } + + @Test + void palletDefaultsToTwoCubicMetresAndBindsOwnedLocation() { + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30001L); + when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(location); + doAnswer(invocation -> { + ((WarehousePalletEntity) invocation.getArgument(0)).setId(2089000000000000002L); + return null; + }).when(repository).insertPallet(org.mockito.ArgumentMatchers.any()); + + Long id = service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + new BigDecimal("12.50"), "IDLE", 30001L, null)); + + assertThat(id).isEqualTo(2089000000000000002L); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getCapacityCbm()).isEqualByComparingTo("2.0000"); + assertThat(captor.getValue().getLocationId()).isEqualTo(30001L); + } + + @Test + void rejectsLocationFromAnotherWarehouseWhenBindingPallet() { + when(repository.findLocation(TENANT, WAREHOUSE, 30002L)).thenReturn(null); + + assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + BigDecimal.ONE, "IDLE", 30002L, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("库位不存在"); + verify(repository, never()).insertPallet(org.mockito.ArgumentMatchers.any()); + } + + @Test + void preventsDeletingLocationThatStillHasPallets() { + when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(new WarehouseLocationEntity()); + when(repository.locationHasPallets(TENANT, WAREHOUSE, 30001L)).thenReturn(true); + + assertThatThrownBy(() -> service.deleteLocation(TENANT, WAREHOUSE, 30001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("仍绑定托盘"); + verify(repository, never()).deleteLocation(30001L); + } + + @Test + void preventsDeletingOccupiedPallet() { + WarehousePalletEntity pallet = new WarehousePalletEntity(); + pallet.setStatus("OCCUPIED"); + when(repository.findPallet(TENANT, WAREHOUSE, 40001L)).thenReturn(pallet); + + assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("占用中的托盘"); + verify(repository, never()).deletePallet(40001L); + } + + @Test + void preventsDeletingScrappedPalletSoItsIdentityCannotBeReused() { + WarehousePalletEntity pallet = new WarehousePalletEntity(); + pallet.setStatus("SCRAPPED"); + when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(pallet); + + assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40003L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("cannot be reused"); + verify(repository, never()).deletePallet(40003L); + } + + @Test + void rejectsUnknownPalletStatus() { + + assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + BigDecimal.ONE, "BROKEN", null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("IDLE、OCCUPIED、SCRAPPED"); + } + + private static LocationRequests.SaveRequest locationRequest(String code) { + return new LocationRequests.SaveRequest("A", code, "STORAGE", + new BigDecimal("1000.000"), "ACTIVE", null); + } + + @Test + void generatesRestartSafePalletCodeAndUsesDefaultVirtualLocation() { + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30003L); + location.setLocationType("VIRTUAL"); + location.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(location); + when(repository.findLocation(TENANT, WAREHOUSE, 30003L)).thenReturn(location); + + service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getPalletCode()).matches("PLT-US-LAX-01-[0-9]{8}-[0-9]+"); + assertThat(captor.getValue().getLocationId()).isEqualTo(30003L); + assertThat(captor.getValue().getId()).isNotNull(); + } + + @Test + void defaultVirtualLocationIsInsertedIdempotentlyForAnExistingWarehouse() { + WarehouseLocationEntity resolved = new WarehouseLocationEntity(); + resolved.setId(30004L); + resolved.setLocationType("VIRTUAL"); + resolved.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(null, resolved); + + WarehouseLocationEntity result = service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE); + + assertThat(result.getId()).isEqualTo(30004L); + ArgumentCaptor location = ArgumentCaptor.forClass(WarehouseLocationEntity.class); + verify(repository).insertDefaultVirtualLocationIgnore(location.capture()); + assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT"); + assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL"); + assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE"); + } + + @Test + void rejectsAConflictingDefaultLocationCode() { + WarehouseLocationEntity conflict = new WarehouseLocationEntity(); + conflict.setLocationType("STORAGE"); + conflict.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(conflict); + + assertThatThrownBy(() -> service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE)) + .isInstanceOf(BizException.class) + .hasMessageContaining("Default virtual location code"); + verify(repository, never()).insertDefaultVirtualLocationIgnore(org.mockito.ArgumentMatchers.any()); + } + + @Test + void scrappedPalletCannotBeReactivated() { + WarehousePalletEntity existing = new WarehousePalletEntity(); + existing.setId(40002L); + existing.setPalletCode("PLT-OLD"); + existing.setStatus("SCRAPPED"); + existing.setVersion(0); + when(repository.findPallet(TENANT, WAREHOUSE, 40002L)).thenReturn(existing); + + assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40002L, + new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null, null, 0))) + .isInstanceOf(BizException.class) + .hasMessageContaining("cannot be reused"); + } + + @Test + void updatePalletChangesCodeAndFbaWarehouseCode() { + WarehousePalletEntity existing = new WarehousePalletEntity(); + existing.setId(40003L); + existing.setPalletCode("PLT-OLD"); + existing.setStatus("IDLE"); + existing.setVersion(2); + when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(existing); + when(repository.updatePallet(org.mockito.ArgumentMatchers.any())).thenReturn(1); + + service.updatePallet(TENANT, WAREHOUSE, 40003L, + new PalletRequests.SaveRequest("PLT-NEW", "WOOD", new BigDecimal("2.5000"), + new BigDecimal("25.00"), "IDLE", null, "updated", "ONT8", 2)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).palletCodeExists(TENANT, WAREHOUSE, "PLT-NEW", 40003L); + verify(repository).updatePallet(captor.capture()); + assertThat(captor.getValue().getPalletCode()).isEqualTo("PLT-NEW"); + assertThat(captor.getValue().getFbaWarehouseCode()).isEqualTo("ONT8"); + assertThat(captor.getValue().getVersion()).isEqualTo(2); + } + + @Test + void updatePalletRejectsDuplicateCodeInSameWarehouse() { + WarehousePalletEntity existing = new WarehousePalletEntity(); + existing.setId(40004L); + existing.setPalletCode("PLT-OLD"); + existing.setStatus("IDLE"); + existing.setVersion(0); + when(repository.findPallet(TENANT, WAREHOUSE, 40004L)).thenReturn(existing); + when(repository.palletCodeExists(TENANT, WAREHOUSE, "PLT-DUP", 40004L)).thenReturn(true); + + assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40004L, + new PalletRequests.SaveRequest("PLT-DUP", "WOOD", BigDecimal.ONE, + BigDecimal.ONE, "IDLE", null, null, "ONT8", 0))) + .isInstanceOf(BizException.class) + .hasMessageContaining("托盘编码已存在"); + verify(repository, never()).updatePallet(org.mockito.ArgumentMatchers.any()); + } + + @Test + void updatePalletRejectsStaleVersionBeforeMutation() { + WarehousePalletEntity existing = new WarehousePalletEntity(); + existing.setId(40005L); + existing.setPalletCode("PLT-OLD"); + existing.setStatus("IDLE"); + existing.setVersion(3); + when(repository.findPallet(TENANT, WAREHOUSE, 40005L)).thenReturn(existing); + + assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40005L, + new PalletRequests.SaveRequest("PLT-STALE", "WOOD", BigDecimal.ONE, + BigDecimal.ONE, "IDLE", null, null, "ONT8", 2))) + .isInstanceOf(BizException.class) + .hasMessageContaining("版本已变化"); + verify(repository, never()).palletCodeExists(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any()); + verify(repository, never()).updatePallet(org.mockito.ArgumentMatchers.any()); + } + + private static SharedWarehouseReference warehouse() { + return new SharedWarehouseReference(WAREHOUSE, "US-LAX-01", "Los Angeles", "ACTIVE"); + } +} diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index d20a1b119..a0991e123 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -2,8 +2,9 @@ ## 职责与边界 -- 负责国内收货仓、地址/能力、往来单位、快递公司、平台公共供应商、国内整柜库存/出入库,以及托盘主数据、库位预占和占用。 +- 负责国内收货仓及现有平台仓库主数据、地址/能力、往来单位、快递公司、平台公共供应商、国内整柜库存/出入库,以及托盘主数据、库位预占和占用。四期只在本模块扩展“海外仓合作仓”仓库类型及其公共主数据契约。 - 入库单、测量和入库状态归 `firstleg/`;订单只通过内部接口校验并保存快照。 +- 四期海外接柜、卸货、海外入库、换标、产品、箱号、库存和库内作业属于海外区域服务,不得落入本国内 Warehouse Service;范围先读 `../../docs/四期/README.md`。 - 公共资源统一 `tenant_id=0`,不得绑定客户或要求客户筛选。 ## 模块入口 @@ -11,6 +12,8 @@ | 目标 | 入口 | |---|---| | 仓库/地址/能力 | `controller/PlatformWarehouseController.java`、`MerchantWarehouseController.java`、`service/WarehouseApplicationService.java` | +| 公共海外收货仓内部目录 | `controller/InternalWarehouseController.java` 的 `/internal/warehouses/shared-overseas`、`/shared-overseas/{warehouseId}`,响应 `dto/SharedOverseasWarehouseResponse.java`;只返回平台公共 `OVERSEAS + ACTIVE + RECEIVING` 仓,供海外资源服务按 warehouseId 引用 | +| 四期海外仓合作仓类型 | 沿用仓库/地址/能力入口;同步核对仓库 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` | | 库位与分配 | `controller/PlatformLocationController.java`、`InternalPalletLocationController.java`、`service/PalletLocationAllocationService.java` | @@ -24,6 +27,7 @@ ## 修改约束 - 内部仓储接口使用 `X-Internal-Service-Token`,不加入 Gateway 公网路由;资源停用/不存在遵循统一 body code。 +- 仓库 ID、类型、状态、地址和服务能力在本模块唯一维护;海外服务不得复制仓库主档。共享海外收货仓目录只接受内部服务令牌,列表和单项必须使用相同的 `tenant_id=0 + OVERSEAS + ACTIVE + RECEIVING` 判定。 - 导入先全量校验再原子写入;调整 DTO、类型或容量规则时同步 Order/Firstleg 客户端和契约测试。 ## 三期托盘、库位与 A0 规则 diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java index 4c16d9d87..5cb2fce13 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java @@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; +import com.baiyue.warehouse.dto.SharedOverseasWarehouseResponse; import com.baiyue.warehouse.service.WarehouseApplicationService; import jakarta.annotation.Resource; import jakarta.annotation.PostConstruct; @@ -11,6 +13,7 @@ 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/warehouses") @@ -39,6 +42,28 @@ public class InternalWarehouseController { return ApiResponse.success(service.activeDetail(PLATFORM_TENANT_ID, id)); } + @GetMapping("/overseas-partners") + public ApiResponse> overseasPartners( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token) { + verifyToken(token); + return ApiResponse.success(service.activeOverseasPartners()); + } + + @GetMapping("/shared-overseas") + public ApiResponse> sharedOverseasWarehouses( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token) { + verifyToken(token); + return ApiResponse.success(service.activeSharedOverseasWarehouses()); + } + + @GetMapping("/shared-overseas/{warehouseId}") + public ApiResponse sharedOverseasWarehouse( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable long warehouseId) { + verifyToken(token); + return ApiResponse.success(service.activeSharedOverseasWarehouse(warehouseId)); + } + private void verifyToken(String token) { byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); if (!MessageDigest.isEqual(expected, supplied)) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java index bc845596b..2f4e5b403 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java @@ -71,6 +71,9 @@ public final class MasterDataQueries { @NotBlank @Size(max = 128) String partnerName, @Schema(description = "单值单位类型", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"}) @Pattern(regexp = "^(CARRIER|SHIPPER|CONSIGNEE)$") String partnerType, + @Schema(description = "船司标准编码;包含 CARRIER 类型时必填,其他类型必须为空", + allowableValues = {"MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"}) + @Pattern(regexp = "^(MATSON|SML|ZIM|COSCO|EMC|HEDE|OTHER)$") String carrierCode, @Schema(description = "联系人", example = "张三") @NotBlank @Size(max = 64) String contactName, @Schema(description = "联系电话", example = "13800138000") diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasPartnerWarehouseResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasPartnerWarehouseResponse.java new file mode 100644 index 000000000..0de6fc8d7 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasPartnerWarehouseResponse.java @@ -0,0 +1,17 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.WarehouseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "OverseasPartnerWarehouseResponse", description = "Active overseas partner warehouse option / 启用的海外合作仓候选") +public record OverseasPartnerWarehouseResponse( + @Schema(description = "Platform public warehouse ID / 平台公共仓库 ID") Long id, + @Schema(description = "Warehouse code / 仓库编码") String warehouseCode, + @Schema(description = "Warehouse name / 仓库名称") String warehouseName, + @Schema(description = "ISO two-letter country code / 国家二字码") String countryCode +) { + public static OverseasPartnerWarehouseResponse of(WarehouseEntity warehouse) { + return new OverseasPartnerWarehouseResponse(warehouse.getId(), warehouse.getWarehouseCode(), + warehouse.getWarehouseName(), warehouse.getCountryCode()); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/SharedOverseasWarehouseResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/SharedOverseasWarehouseResponse.java new file mode 100644 index 000000000..c3d31cfcc --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/SharedOverseasWarehouseResponse.java @@ -0,0 +1,19 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.WarehouseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "SharedOverseasWarehouseResponse", + description = "Domestic-authoritative active shared overseas receiving warehouse") +public record SharedOverseasWarehouseResponse( + Long warehouseId, + String warehouseCode, + String warehouseName, + String countryCode, + String status +) { + public static SharedOverseasWarehouseResponse of(WarehouseEntity warehouse) { + return new SharedOverseasWarehouseResponse(warehouse.getId(), warehouse.getWarehouseCode(), + warehouse.getWarehouseName(), warehouse.getCountryCode(), warehouse.getStatus()); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java index 2065c4115..796b1020c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -29,7 +29,7 @@ public final class WarehouseRequests { @NotBlank @Size(max = 50) String warehouseCode, @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank @Size(max = 120) String warehouseName, - @Schema(description = "Warehouse type; defaults to DOMESTIC / 仓库类型,默认 DOMESTIC", example = "FBA", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}, defaultValue = "DOMESTIC") + @Schema(description = "Warehouse type; defaults to DOMESTIC / 仓库类型,默认 DOMESTIC", example = "OVERSEAS_PARTNER", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}, defaultValue = "DOMESTIC") @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS|OVERSEAS_PARTNER") String warehouseType, @Schema(description = "ISO 3166-1 alpha-2 country code; DOMESTIC defaults to CN / ISO 两位国家代码,DOMESTIC 默认 CN", example = "US", pattern = "[A-Z]{2}") @Pattern(regexp = "[A-Z]{2}") String countryCode, @@ -104,7 +104,7 @@ public final class WarehouseRequests { String countryCode, @Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE") String capabilityCode, - @Schema(description = "Warehouse type filter / 仓库类型筛选", example = "DOMESTIC", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}) + @Schema(description = "Warehouse type filter / 仓库类型筛选", example = "OVERSEAS_PARTNER", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}) @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS|OVERSEAS_PARTNER") String warehouseType, @Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) @Pattern(regexp = "ACTIVE|INACTIVE") String status diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java index a476c5133..ad39a13e5 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java @@ -28,6 +28,9 @@ public class PartnerEntity extends BaseEntity { private String partnerName; @Schema(description = "类型", example = "CARRIER", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"}) private String partnerType; + @Schema(description = "船司标准编码;船公司必填,其他类型必须为空", example = "MATSON", + allowableValues = {"MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"}) + private String carrierCode; @TableField(exist = false) @Schema(description = "单位类型集合", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"}) private List types; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java index e391ee0d4..03e447e25 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java @@ -27,7 +27,8 @@ public class WarehouseEntity extends BaseEntity { private String warehouseCode; @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓") private String warehouseName; - @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS") + @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS_PARTNER", + allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}) private String warehouseType; @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US") private String countryCode; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 9b7c1bff3..304268c00 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -59,6 +59,15 @@ public class WarehouseRepository { .eq(WarehouseEntity::getStatus, "ACTIVE")); } + public List findActiveByType(long tenantId, String warehouseType) { + return warehouses.selectList(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getWarehouseType, warehouseType) + .eq(WarehouseEntity::getStatus, "ACTIVE") + .orderByAsc(WarehouseEntity::getWarehouseName) + .orderByAsc(WarehouseEntity::getId)); + } + public boolean existsCode(long tenantId, String code, Long excludedId) { return warehouses.selectCount(new LambdaQueryWrapper() .eq(WarehouseEntity::getTenantId, tenantId) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java index 2069c48ac..0d5487a25 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java @@ -35,10 +35,12 @@ public class PartnerImportService { private static final String TEMPLATE_VERSION = "1"; private static final Set ALLOWED_TYPES = Set.of( "CARRIER", "SHIPPER", "CONSIGNEE"); + private static final Set ALLOWED_CARRIER_CODES = Set.of( + "MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"); private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); private static final String[] HEADERS = { "单位编码*", "单位名称*", "单位类型*", "联系人*", "联系电话*", "联系邮箱", - "国家二字码*", "省/州", "城市*", "区/县", "详细地址*", "邮政编码", "状态*", "备注" + "船司标准编码", "国家二字码*", "省/州", "城市*", "区/县", "详细地址*", "邮政编码", "状态*", "备注" }; @Resource @@ -107,9 +109,9 @@ public class PartnerImportService { ImportRow item = new ImportRow(index + 1, value(row, 0, formatter), value(row, 1, formatter), parseTypes(value(row, 2, formatter)), value(row, 3, formatter), value(row, 4, formatter), value(row, 5, formatter), - value(row, 6, formatter).toUpperCase(Locale.ROOT), value(row, 7, formatter), - value(row, 8, formatter), value(row, 9, formatter), value(row, 10, formatter), - value(row, 11, formatter), value(row, 12, formatter).toUpperCase(Locale.ROOT), value(row, 13, formatter)); + value(row, 6, formatter).toUpperCase(Locale.ROOT), value(row, 7, formatter).toUpperCase(Locale.ROOT), + value(row, 8, formatter), value(row, 9, formatter), value(row, 10, formatter), value(row, 11, formatter), + value(row, 12, formatter), value(row, 13, formatter).toUpperCase(Locale.ROOT), value(row, 14, formatter)); validate(tenantId, item, rows, errors); rows.add(item); } @@ -134,6 +136,11 @@ public class PartnerImportService { if (row.types.isEmpty()) errors.add(error(row, "单位类型", "至少填写一个类型")); row.types.stream().filter(type -> !ALLOWED_TYPES.contains(type)) .forEach(type -> errors.add(error(row, "单位类型", "不支持类型 " + type))); + if (row.types.contains("CARRIER") && !ALLOWED_CARRIER_CODES.contains(row.carrierCode)) { + errors.add(error(row, "船司标准编码", "船公司必须选择 MATSON、SML、ZIM、COSCO、EMC、HEDE 或 OTHER")); + } else if (!row.types.contains("CARRIER") && !row.carrierCode.isBlank()) { + errors.add(error(row, "船司标准编码", "非船公司必须留空")); + } required(row, errors, "联系人", row.contactName, 64); required(row, errors, "联系电话", row.contactPhone, 32); if (row.email.length() > 128 || (!row.email.isBlank() && !EMAIL.matcher(row.email).matches())) { @@ -162,7 +169,8 @@ public class PartnerImportService { entity.setPartnerCode(row.code); entity.setPartnerName(row.name); entity.setTypes(row.types); - entity.setPartnerType(row.types.get(0)); + entity.setPartnerType(row.types.contains("CARRIER") ? "CARRIER" : row.types.get(0)); + entity.setCarrierCode(nullable(row.carrierCode)); entity.setContactName(row.contactName); entity.setContactPhone(row.contactPhone); entity.setContactEmail(nullable(row.email)); @@ -216,6 +224,6 @@ public class PartnerImportService { } private record ImportRow(int row, String code, String name, List types, String contactName, - String contactPhone, String email, String countryCode, String province, String city, + String contactPhone, String email, String carrierCode, String countryCode, String province, String city, String district, String address, String postalCode, String status, String remark) { } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java index 4b668d769..f906ead93 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java @@ -24,6 +24,8 @@ public class PlatformPartnerQueryService { private static final long PUBLIC_TENANT_ID = 0L; private static final Set ALLOWED_QUERY_TYPES = Set.of( "CARRIER", "SHIPPER", "CONSIGNEE"); + private static final Set ALLOWED_CARRIER_CODES = Set.of( + "MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"); @Resource private PartnerRepository repository; @@ -121,10 +123,12 @@ public class PlatformPartnerQueryService { private static void copy(PartnerEntity entity, PartnerSaveRequest request) { List types = normalizeTypes(request.types(), request.partnerType()); + String carrierCode = normalizeCarrierCode(types, request.carrierCode()); entity.setPartnerCode(request.partnerCode()); entity.setPartnerName(request.partnerName()); entity.setTypes(types); entity.setPartnerType(legacyType(types)); + entity.setCarrierCode(carrierCode); entity.setContactName(request.contactName()); entity.setContactPhone(request.contactPhone()); entity.setContactEmail(request.contactEmail()); @@ -154,6 +158,25 @@ public class PlatformPartnerQueryService { } private static String legacyType(List types) { - return types.get(0); + return types.contains("CARRIER") ? "CARRIER" : types.get(0); + } + + static String normalizeCarrierCode(List types, String carrierCode) { + boolean carrier = types.contains("CARRIER"); + if (!carrier) { + if (carrierCode != null && !carrierCode.isBlank()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "非船公司不得设置船司标准编码"); + } + return null; + } + if (carrierCode == null || carrierCode.isBlank()) { + throw new BizException(ResultCode.BAD_REQUEST, "船公司必须选择船司标准编码"); + } + String normalized = carrierCode.trim().toUpperCase(Locale.ROOT); + if (!ALLOWED_CARRIER_CODES.contains(normalized)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "船司标准编码仅支持 MATSON、SML、ZIM、COSCO、EMC、HEDE 或 OTHER"); + } + return normalized; } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index 76456870f..0728ba84d 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -7,7 +7,9 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; import com.baiyue.warehouse.dto.WarehouseSummaryResponse; +import com.baiyue.warehouse.dto.SharedOverseasWarehouseResponse; import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -86,6 +88,40 @@ public class WarehouseApplicationService { return repository.activePage(tenantId, query); } + public List activeOverseasPartners() { + return repository.findActiveByType(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, + WarehouseSaveNormalizer.TYPE_OVERSEAS_PARTNER) + .stream() + .map(OverseasPartnerWarehouseResponse::of) + .toList(); + } + + public List activeSharedOverseasWarehouses() { + return repository.findActiveByType(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, + WarehouseSaveNormalizer.TYPE_OVERSEAS) + .stream() + .filter(warehouse -> hasReceivingCapability(warehouse.getId())) + .map(SharedOverseasWarehouseResponse::of) + .toList(); + } + + public SharedOverseasWarehouseResponse activeSharedOverseasWarehouse(long warehouseId) { + WarehouseEntity warehouse = repository.findOwned(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, warehouseId); + if (warehouse == null || !"ACTIVE".equals(warehouse.getStatus()) + || !WarehouseSaveNormalizer.TYPE_OVERSEAS.equals(warehouse.getWarehouseType()) + || !hasReceivingCapability(warehouseId)) { + throw new BizException(ResultCode.NOT_FOUND, + "启用的公共海外收货仓不存在"); + } + return SharedOverseasWarehouseResponse.of(warehouse); + } + + private boolean hasReceivingCapability(long warehouseId) { + return repository.findCapabilities(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, warehouseId).stream() + .anyMatch(capability -> "RECEIVING".equals(capability.getCapabilityCode()) + && Boolean.TRUE.equals(capability.getEnabled())); + } + public WarehouseDetailResponse activeDetail(long tenantId, long id) { WarehouseEntity warehouse = repository.findOwned(tenantId, id); if (warehouse == null) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java index e7ab7b1b6..4608f050b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java @@ -56,7 +56,7 @@ public class WarehouseImportService { Row instruction = sheet.createRow(1); instruction.createCell(0).setCellValue("说明"); instruction.createCell(1).setCellValue( - "默认仅新增;仓库类型默认 DOMESTIC,状态默认 ACTIVE;DOMESTIC 默认 CN/UTC+8;FBA 名称可不填并默认使用编码,多时区州必须显式填写时区;更新不覆盖联系人和备注"); + "默认仅新增;仓库类型默认 DOMESTIC,可填 OVERSEAS_PARTNER 表示海外合作仓;状态默认 ACTIVE;DOMESTIC 默认 CN/UTC+8;FBA 名称可不填并默认使用编码,多时区州必须显式填写时区;更新不覆盖联系人和备注"); Row header = sheet.createRow(2); for (int i = 0; i < HEADERS.length; i++) { header.createCell(i).setCellValue(HEADERS[i]); @@ -203,7 +203,7 @@ public class WarehouseImportService { optionalLength(row, errors, "仓库名称", row.name(), 120); } if (!row.type().isBlank() && !row.type().matches("(?i)DOMESTIC|FBA|OVERSEAS|OVERSEAS_PARTNER")) { - errors.add(new WarehouseImportError(row.row(), "仓库类型", "只能填写 DOMESTIC、FBA 或 OVERSEAS")); + errors.add(new WarehouseImportError(row.row(), "仓库类型", "只能填写 DOMESTIC、FBA、OVERSEAS 或 OVERSEAS_PARTNER")); } if (!row.countryCode().isBlank() && !row.countryCode().matches("(?i)[A-Z]{2}")) { errors.add(new WarehouseImportError(row.row(), "国家二字码", "必须是两个英文字母")); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java index fa4cf84ec..69d09eacf 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java @@ -71,7 +71,8 @@ final class WarehouseSaveNormalizer { static NormalizedSaveRequest normalize(WarehouseRequests.SaveRequest request, long tenantId) { String warehouseType = upperOrDefault(request.warehouseType(), TYPE_DOMESTIC); if (!Set.of(TYPE_DOMESTIC, TYPE_FBA, TYPE_OVERSEAS, TYPE_OVERSEAS_PARTNER).contains(warehouseType)) { - throw new BizException(ResultCode.VALIDATION_FAILED, "Warehouse type must be DOMESTIC, FBA, OVERSEAS or OVERSEAS_PARTNER / 仓库类型只能为 DOMESTIC、FBA、OVERSEAS 或 OVERSEAS_PARTNER"); + throw new BizException(ResultCode.VALIDATION_FAILED, + "Warehouse type must be DOMESTIC, FBA, OVERSEAS or OVERSEAS_PARTNER / 仓库类型只能为 DOMESTIC、FBA、OVERSEAS 或 OVERSEAS_PARTNER"); } String status = upperOrDefault(request.status(), STATUS_ACTIVE); if (!STATUS_ACTIVE.equals(status) && !"INACTIVE".equals(status)) { diff --git a/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql index ee0cb5905..446ce56eb 100644 --- a/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql +++ b/warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql @@ -102,7 +102,8 @@ CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id'); CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code'); CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name'); -CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'' AFTER partner_type'); +CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER carrier_code'); CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name'); CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL'); CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email'); @@ -118,6 +119,8 @@ CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL'); CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'); CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0'); CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0'); +UPDATE wh_partner SET carrier_code = 'OTHER' + WHERE id = 191000000000000001 AND partner_type = 'CARRIER' AND carrier_code IS NULL; -- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。 CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id'); diff --git a/warehouse/src/main/resources/db/warehouse-partner.sql b/warehouse/src/main/resources/db/warehouse-partner.sql index 10955f144..e4746c15d 100644 --- a/warehouse/src/main/resources/db/warehouse-partner.sql +++ b/warehouse/src/main/resources/db/warehouse-partner.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS wh_partner ( data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围', partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称', partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CARRIER、SHIPPER或CONSIGNEE', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填', contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码', province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县', @@ -16,7 +17,8 @@ CREATE TABLE IF NOT EXISTS wh_partner ( deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted), KEY idx_wh_partner_tenant_status(tenant_id,status,deleted), - CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')) + CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')), + CONSTRAINT chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')) ) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据'; CREATE TABLE IF NOT EXISTS wh_partner_type ( partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0', diff --git a/warehouse/src/main/resources/db/warehouse-public-master-data.sql b/warehouse/src/main/resources/db/warehouse-public-master-data.sql index 6ea011f7c..e14c622a6 100644 --- a/warehouse/src/main/resources/db/warehouse-public-master-data.sql +++ b/warehouse/src/main/resources/db/warehouse-public-master-data.sql @@ -162,7 +162,7 @@ JOIN wh_warehouse warehouse ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- 国内固定作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 +-- 四个固定平台作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 INSERT INTO wh_service_capability ( id, tenant_id, warehouse_id, capability_code, capability_name, enabled, cutoff_time, description, created_by, created_at, updated_by, updated_at, deleted, version @@ -171,7 +171,11 @@ VALUES (193000000000400001,0,193000000000200001,'RECEIVING','头程收货',1,'16:00:00', '义乌仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), (193000000000400002,0,193000000000200002,'RECEIVING','头程收货',1,'16:00:00', - '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) + '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00', + '洛杉矶仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00', + '纽约仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) ON DUPLICATE KEY UPDATE capability_name = VALUES(capability_name), enabled = VALUES(enabled), @@ -677,11 +681,11 @@ BEGIN IF (SELECT COUNT(*) FROM wh_service_capability capability WHERE capability.tenant_id = 0 - AND capability.warehouse_id IN (193000000000200001,193000000000200002) + AND capability.warehouse_id IN (193000000000200001,193000000000200002,193000000000200003,193000000000200004) AND capability.capability_code = 'RECEIVING' AND capability.enabled = 1 - AND capability.deleted = 0) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内固定作业仓收货能力写入校验失败'; + AND capability.deleted = 0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台作业仓收货能力写入校验失败'; END IF; END// DELIMITER ; diff --git a/warehouse/src/main/resources/db/warehouse.sql b/warehouse/src/main/resources/db/warehouse.sql index 9ad8e6691..d61950cf9 100644 --- a/warehouse/src/main/resources/db/warehouse.sql +++ b/warehouse/src/main/resources/db/warehouse.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库固定使用 tenant_id=0', warehouse_code VARCHAR(50) NOT NULL COMMENT '平台内唯一仓库编码', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', - warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC、FBA、OVERSEAS、OVERSEAS_PARTNER', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地UTC偏移量', contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java index aa3ff07a4..f246fcd69 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java @@ -3,6 +3,8 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; +import com.baiyue.warehouse.dto.SharedOverseasWarehouseResponse; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.service.WarehouseApplicationService; import org.junit.jupiter.api.BeforeEach; @@ -71,4 +73,28 @@ class InternalWarehouseControllerTest { verifyNoInteractions(service); } + + @Test + void listsActiveOverseasPartnerOptionsForInternalCallers() { + var option = new OverseasPartnerWarehouseResponse(200002L, "US-PARTNER-LAX", "洛杉矶合作仓", "US"); + when(service.activeOverseasPartners()).thenReturn(List.of(option)); + + var response = controller.overseasPartners(INTERNAL_TOKEN); + + assertThat(response.data()).containsExactly(option); + verify(service).activeOverseasPartners(); + } + + @Test + void exposesSharedOverseasReceivingDirectoryOnlyWithInternalToken() { + var warehouse = new SharedOverseasWarehouseResponse(200003L, "US-LAX-01", + "洛杉矶海外仓", "US", "ACTIVE"); + when(service.activeSharedOverseasWarehouse(200003L)).thenReturn(warehouse); + + assertThat(controller.sharedOverseasWarehouse(INTERNAL_TOKEN, 200003L).data()) + .isEqualTo(warehouse); + assertThatThrownBy(() -> controller.sharedOverseasWarehouse("bad", 200003L)) + .isInstanceOf(BizException.class); + verify(service).activeSharedOverseasWarehouse(200003L); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 96ef04a60..6c7c213f2 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -25,11 +25,12 @@ class WarehouseSchemaContractTest { void shouldHandleCase02() throws IOException { String sql = read("warehouse-partner.sql"); assertColumns(sql, "wh_partner", "data_scope_id", "partner_code", "partner_name", "partner_type", "contact_name", - "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line", + "carrier_code", "contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line", "postal_code", "status", "remark"); assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_partner_type") .contains("idx_wh_partner_type_scope") .contains("chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))") + .contains("chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))") .contains("chk_wh_partner_type_value CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))") .doesNotContain("CUSTOMS_BROKER", "SUPPLIER或BOTH"); assertBaseColumns(sql); @@ -100,6 +101,8 @@ class WarehouseSchemaContractTest { .contains("CALL add_column_if_missing('wh_warehouse_address'") .contains("CALL add_column_if_missing('wh_service_capability'") .contains("CALL add_column_if_missing('wh_partner'") + .contains("CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL") + .contains("UPDATE wh_partner SET carrier_code = 'OTHER'") .contains("CALL add_column_if_missing('wh_express_company'") .contains("DROP PROCEDURE IF EXISTS add_column_if_missing") .doesNotContain("ADD COLUMN IF NOT EXISTS") @@ -109,7 +112,7 @@ class WarehouseSchemaContractTest { } @Test - void shouldSeedReceivingCapabilityForDomesticOperatingWarehouses() throws IOException { + void shouldSeedReceivingCapabilityForFixedOperatingWarehouses() throws IOException { String masterData = read("warehouse-public-master-data.sql"); String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); @@ -117,7 +120,11 @@ class WarehouseSchemaContractTest { assertThat(sql) .contains("193000000000200001,'RECEIVING','头程收货',1") .contains("193000000000200002,'RECEIVING','头程收货',1") - .contains("国内固定作业仓收货能力写入校验失败") + .contains("193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1") + .contains("'洛杉矶仓海外接柜入仓收货能力'") + .contains("193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1") + .contains("'纽约仓海外接柜入仓收货能力'") + .contains("固定平台作业仓收货能力写入校验失败") .contains("warehouse.warehouse_type = 'FBA'") .contains("FBA公共地址不得创建平台作业能力"); } @@ -146,7 +153,13 @@ class WarehouseSchemaContractTest { String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); assertThat(tableDefinition(unified, "wh_partner")) .contains("chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))") + .contains("carrier_code VARCHAR(16) NULL") + .contains("chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))") .doesNotContain("CUSTOMS_BROKER", "SUPPLIER或BOTH"); + assertThat(unified) + .contains("CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL") + .contains("UPDATE wh_partner SET carrier_code = 'OTHER'") + .contains("queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code'"); } private static String read(String file) throws IOException { diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java index 17618504f..e4e514289 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerImportServiceTest.java @@ -40,7 +40,7 @@ class PartnerImportServiceTest { void shouldImportValidMultiTypeRow() throws Exception { byte[] file = withRows(service.template(), new String[][] { {"CAR-100", "测试船公司", "CARRIER,SHIPPER", "张三", "13800138000", "test@example.com", - "CN", "广东省", "深圳市", "南山区", "测试路 1 号", "518000", "ACTIVE", "测试备注"} + "MATSON", "CN", "广东省", "深圳市", "南山区", "测试路 1 号", "518000", "ACTIVE", "测试备注"} }); PartnerImportResult result = service.importFile(0L, "partners.xlsx", file); @@ -52,6 +52,7 @@ class PartnerImportServiceTest { assertThat(captor.getValue().getTenantId()).isZero(); assertThat(captor.getValue().getDataScopeId()).isZero(); assertThat(captor.getValue().getTypes()).containsExactly("CARRIER", "SHIPPER"); + assertThat(captor.getValue().getCarrierCode()).isEqualTo("MATSON"); assertThat(captor.getValue().getRemark()).isEqualTo("测试备注"); } @@ -59,8 +60,8 @@ class PartnerImportServiceTest { @DisplayName("文件内任一错误时整批零写入并返回行号") void shouldRejectWholeBatchWhenAnyRowIsInvalid() throws Exception { byte[] file = withRows(service.template(), new String[][] { - {"CAR-100", "测试船公司一", "CARRIER", "张三", "13800138000", "", "CN", "", "深圳市", "", "测试路 1 号", "", "ACTIVE", ""}, - {"CAR-100", "测试单位二", "UNKNOWN", "李四", "13900139000", "bad-email", "C", "", "", "", "", "", "ENABLED", ""} + {"CAR-100", "测试船公司一", "CARRIER", "张三", "13800138000", "", "MATSON", "CN", "", "深圳市", "", "测试路 1 号", "", "ACTIVE", ""}, + {"CAR-100", "测试单位二", "UNKNOWN", "李四", "13900139000", "bad-email", "", "C", "", "", "", "", "", "ENABLED", ""} }); PartnerImportResult result = service.importFile(0L, "partners.xlsx", file); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java index 453e3bb6d..2f5ae5bb6 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java @@ -79,6 +79,7 @@ class PlatformMasterDataManagementServiceTest { assertThat(entity.getValue().getPartnerCode()).isEqualTo("CUS-001"); assertThat(entity.getValue().getPartnerName()).isEqualTo("示例客户"); assertThat(entity.getValue().getTypes()).containsExactly("CARRIER", "SHIPPER"); + assertThat(entity.getValue().getCarrierCode()).isEqualTo("OTHER"); assertThat(entity.getValue().getRemark()).isEqualTo("测试备注"); } @@ -210,7 +211,7 @@ class PlatformMasterDataManagementServiceTest { } private static PartnerSaveRequest partnerRequest() { - return new PartnerSaveRequest("CUS-001", "示例客户", "CARRIER", "张三", "13800138000", + return new PartnerSaveRequest("CUS-001", "示例客户", "CARRIER", "OTHER", "张三", "13800138000", "test@example.com", "CN", "广东省", "深圳市", "南山区", "示例地址", "518000", "ACTIVE", java.util.List.of("CARRIER", "SHIPPER"), "测试备注"); } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index a3eddd161..e22cc4b92 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -116,4 +116,56 @@ class WarehouseApplicationServiceTest { .isEqualTo(ResultCode.VALIDATION_FAILED); verify(repository, never()).insertWarehouse(any()); } + + @Test + void createsOverseasPartnerWarehouseAsPlatformPublicMasterData() { + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "US-PARTNER-LAX", "洛杉矶合作仓", + "OVERSEAS_PARTNER", "US", "UTC-8", "Alex", "+1-213-555-0100", null, null, null, + new WarehouseRequests.AddressRequest(null, "US", "CA", "Los Angeles", null, + "90001", "123 Partner Ave", null, null, null), + java.util.List.of(new WarehouseRequests.CapabilityRequest("STORAGE", "仓储", true, null, null))); + + service.create(request); + + var warehouse = org.mockito.ArgumentCaptor.forClass(WarehouseEntity.class); + verify(repository).insertWarehouse(warehouse.capture()); + assertThat(warehouse.getValue().getTenantId()).isZero(); + assertThat(warehouse.getValue().getWarehouseType()).isEqualTo("OVERSEAS_PARTNER"); + assertThat(warehouse.getValue().getCountryCode()).isEqualTo("US"); + assertThat(warehouse.getValue().getTimezone()).isEqualTo("UTC-8"); + } + + @Test + void exposesOnlyActiveOverseasPartnerWarehousesToInternalConsumers() { + WarehouseEntity partner = new WarehouseEntity(); + partner.setId(2L); + partner.setWarehouseCode("US-PARTNER-LAX"); + partner.setWarehouseName("洛杉矶合作仓"); + partner.setCountryCode("US"); + when(repository.findActiveByType(0L, "OVERSEAS_PARTNER")).thenReturn(java.util.List.of(partner)); + + assertThat(service.activeOverseasPartners()) + .extracting("id", "warehouseCode", "warehouseName", "countryCode") + .containsExactly(tuple(2L, "US-PARTNER-LAX", "洛杉矶合作仓", "US")); + verify(repository).findActiveByType(0L, "OVERSEAS_PARTNER"); + } + + @Test + void exposesOnlyActiveSharedOverseasWarehousesWithReceivingCapability() { + WarehouseEntity receiving = new WarehouseEntity(); + receiving.setId(3L); receiving.setWarehouseCode("US-LAX-01"); + receiving.setWarehouseName("洛杉矶海外仓"); receiving.setCountryCode("US"); + receiving.setWarehouseType("OVERSEAS"); receiving.setStatus("ACTIVE"); + ServiceCapabilityEntity capability = new ServiceCapabilityEntity(); + capability.setCapabilityCode("RECEIVING"); capability.setEnabled(true); + when(repository.findActiveByType(0L, "OVERSEAS")).thenReturn(java.util.List.of(receiving)); + when(repository.findOwned(0L, 3L)).thenReturn(receiving); + when(repository.findCapabilities(0L, 3L)).thenReturn(java.util.List.of(capability)); + + assertThat(service.activeSharedOverseasWarehouses()) + .extracting("warehouseId", "warehouseCode", "status") + .containsExactly(tuple(3L, "US-LAX-01", "ACTIVE")); + assertThat(service.activeSharedOverseasWarehouse(3L).warehouseName()) + .isEqualTo("洛杉矶海外仓"); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java index aa7ef5033..1e0837d5a 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java @@ -64,6 +64,22 @@ class WarehouseImportServiceTest { assertThat(captor.getAllValues().get(1).warehouseName()).isEqualTo("FBA-LAX9"); } + @Test + @DisplayName("海外合作仓类型可通过批量导入进入公共仓库主数据") + void shouldImportOverseasPartnerWarehouse() throws Exception { + byte[] file = withRows(service.template(), new String[][] { + {"US-PARTNER-LAX", "洛杉矶合作仓", "OVERSEAS_PARTNER", "US", "CA", "Los Angeles", "", "123 Partner Ave", "90001", "UTC-8", "", "", "", "", ""} + }); + + WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null); + + assertThat(result.success()).isTrue(); + var captor = org.mockito.ArgumentCaptor.forClass(WarehouseRequests.SaveRequest.class); + verify(warehouseService).create(captor.capture()); + assertThat(captor.getValue().tenantId()).isZero(); + assertThat(captor.getValue().warehouseType()).isEqualTo("OVERSEAS_PARTNER"); + } + @Test @DisplayName("任一行错误时整批零写入") void shouldRejectWholeBatchWhenAnyRowInvalid() throws Exception { diff --git a/warehouse/src/test/resources/db/verify-warehouse-public-master-data.sql b/warehouse/src/test/resources/db/verify-warehouse-public-master-data.sql index a7beada7f..5303ff3d2 100644 --- a/warehouse/src/test/resources/db/verify-warehouse-public-master-data.sql +++ b/warehouse/src/test/resources/db/verify-warehouse-public-master-data.sql @@ -19,6 +19,38 @@ SELECT COUNT(*) AS fixed_platform_address_count AND address.deleted = 0 AND warehouse.deleted = 0; +SELECT COUNT(*) AS fixed_platform_receiving_capability_count + FROM wh_service_capability capability + WHERE capability.tenant_id = 0 + AND capability.warehouse_id IN ( + 193000000000200001, 193000000000200002, + 193000000000200003, 193000000000200004 + ) + AND capability.capability_code = 'RECEIVING' + AND capability.enabled = 1 + AND capability.deleted = 0; + +SELECT COUNT(*) AS overseas_receiving_capability_violations + FROM wh_service_capability capability + WHERE capability.tenant_id = 0 + AND ( + (capability.id = 193000000000400003 + AND (capability.warehouse_id <> 193000000000200003 + OR capability.capability_code <> 'RECEIVING' + OR capability.capability_name <> '海外收货' + OR capability.description <> '洛杉矶仓海外接柜入仓收货能力' + OR capability.enabled <> 1 + OR capability.deleted <> 0)) + OR + (capability.id = 193000000000400004 + AND (capability.warehouse_id <> 193000000000200004 + OR capability.capability_code <> 'RECEIVING' + OR capability.capability_name <> '海外收货' + OR capability.description <> '纽约仓海外接柜入仓收货能力' + OR capability.enabled <> 1 + OR capability.deleted <> 0)) + ); + SELECT COUNT(*) AS fba_public_warehouse_count FROM wh_warehouse WHERE tenant_id = 0 diff --git a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs index cfcd5a871..363e10047 100644 --- a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs +++ b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs @@ -111,6 +111,12 @@ assert.doesNotMatch(sql, /contact_name\s*=\s*VALUES\(contact_name\)/) assert.doesNotMatch(sql, /contact_phone\s*=\s*VALUES\(contact_phone\)/) assert.doesNotMatch(sql, /contact_email\s*=\s*VALUES\(contact_email\)/) assert.doesNotMatch(sql, /remark\s*=\s*VALUES\(remark\)/) -assert.doesNotMatch(sql, /INSERT INTO wh_service_capability/i) +assert.match(sql, /四个固定平台作业仓必须启用收货能力/) +assert.match(sql, /INSERT INTO wh_service_capability[\s\S]+?'RECEIVING'/i) +assert.match(sql, /193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'洛杉矶仓海外接柜入仓收货能力'/) +assert.match(sql, /193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'纽约仓海外接柜入仓收货能力'/) +assert.match(sql, /warehouse_id IN \(193000000000200001,193000000000200002,193000000000200003,193000000000200004\)[\s\S]*?<> 4 THEN[\s\S]*?固定平台作业仓收货能力写入校验失败/) +const fbaBlock = sql.slice(sql.indexOf('CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data')) +assert.doesNotMatch(fbaBlock, /INSERT INTO wh_service_capability/i) assert.match(sql, /FBA公共主数据地址种子ID与既有数据冲突/) assert.match(sql, /FBA公共地址不得创建平台作业能力/)